Optimize forward rendering and add benchmark

Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-20 14:36:51 +00:00
co-authored by heaust
parent 000bfd63bf
commit abfa428464
18 changed files with 390 additions and 97 deletions
+2
View File
@@ -12,6 +12,7 @@ export type GraphTexture = {
format?: string; format?: string;
size?: [number | "canvas", number | "canvas", number?]; size?: [number | "canvas", number | "canvas", number?];
usage?: string[]; usage?: string[];
mipLevelCount?: number;
transient?: boolean; transient?: boolean;
}; };
@@ -19,6 +20,7 @@ export type GraphSampler = {
id: string; id: string;
magFilter?: "nearest" | "linear"; magFilter?: "nearest" | "linear";
minFilter?: "nearest" | "linear"; minFilter?: "nearest" | "linear";
mipmapFilter?: "nearest" | "linear";
addressModeU?: "clamp-to-edge" | "repeat" | "mirror-repeat"; addressModeU?: "clamp-to-edge" | "repeat" | "mirror-repeat";
addressModeV?: "clamp-to-edge" | "repeat" | "mirror-repeat"; addressModeV?: "clamp-to-edge" | "repeat" | "mirror-repeat";
}; };
+28 -34
View File
@@ -38,6 +38,7 @@ const rows = [
["meshInfo", 16, "u32"], ["meshInfo", 16, "u32"],
["bounds", 32, "f32"], ["bounds", 32, "f32"],
["cameras", 80, "f32"], ["cameras", 80, "f32"],
["cameraMatrices", 80, "f32"],
["materials", 48, "f32"], ["materials", 48, "f32"],
["materialTextures", 32, "u32"], ["materialTextures", 32, "u32"],
["pointLights", 32, "f32"], ["pointLights", 32, "f32"],
@@ -94,7 +95,7 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
const basicForwardShader = /* wgsl */ ` const basicForwardShader = /* wgsl */ `
struct Accent { color: vec4<f32> } struct Accent { color: vec4<f32> }
struct VertexOutput { struct VertexOutput {
@builtin(position) position: vec4<f32>, @invariant @builtin(position) position: vec4<f32>,
@location(0) normal: vec3<f32>, @location(0) normal: vec3<f32>,
@location(1) @interpolate(flat) mesh: u32, @location(1) @interpolate(flat) mesh: u32,
@location(2) @interpolate(flat) material: u32, @location(2) @interpolate(flat) material: u32,
@@ -107,7 +108,7 @@ struct VertexOutput {
@group(0) @binding(4) var<storage, read> scales: array<vec4<f32>>; @group(0) @binding(4) var<storage, read> scales: array<vec4<f32>>;
@group(0) @binding(5) var<storage, read> meshInfo: array<u32>; @group(0) @binding(5) var<storage, read> meshInfo: array<u32>;
@group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>; @group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>;
@group(0) @binding(7) var<storage, read> cameras: array<vec4<f32>>; @group(0) @binding(7) var<storage, read> cameraMatrices: array<vec4<f32>>;
fn rotate(q: vec4<f32>, value: vec3<f32>) -> vec3<f32> { fn rotate(q: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
return value + 2.0 * cross(q.xyz, cross(q.xyz, value) + q.w * value); return value + 2.0 * cross(q.xyz, cross(q.xyz, value) + q.w * value);
@@ -119,18 +120,14 @@ fn vertex(@location(0) point: vec3<f32>, @builtin(instance_index) packed: u32) -
let visible = meshInfo[instance * 4u + 2u]; let visible = meshInfo[instance * 4u + 2u];
let transformed = rotate(quaternions[instance], point * scales[instance].xyz) + positions[instance].xyz; let transformed = rotate(quaternions[instance], point * scales[instance].xyz) + positions[instance].xyz;
var clip = vec4<f32>(transformed, 1.0); var clip = vec4<f32>(transformed, 1.0);
if (cameras[2].w != 0.0) { if (cameraMatrices[4].w != 0.0) {
let cameraNode = u32(cameras[1].x); let world = vec4(transformed, 1.0);
let inverse = vec4<f32>(-quaternions[cameraNode].xyz, quaternions[cameraNode].w); clip = vec4(
let view = rotate(inverse, transformed - positions[cameraNode].xyz); dot(cameraMatrices[0], world),
if (cameras[1].y == 1.0) { dot(cameraMatrices[1], world),
let size = max(cameras[1].z, 0.0001); dot(cameraMatrices[2], world),
clip = vec4<f32>(view.x / (size * cameras[0].y * 0.5), view.y / (size * 0.5), -view.z / cameras[0].w, 1.0); dot(cameraMatrices[3], world),
} else { );
let focal = 1.0 / tan(cameras[0].x * 0.5);
let depth = (-view.z * cameras[0].w - cameras[0].z * cameras[0].w) / (cameras[0].w - cameras[0].z);
clip = vec4<f32>(view.x * focal / cameras[0].y, view.y * focal, depth, -view.z);
}
} }
var output: VertexOutput; var output: VertexOutput;
output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), clip, visible != 0u); output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), clip, visible != 0u);
@@ -159,7 +156,7 @@ function pbrShader(mask: number) {
const normalTexture = mask & 4; const normalTexture = mask & 4;
return /* wgsl */ ` return /* wgsl */ `
struct VertexOutput { struct VertexOutput {
@builtin(position) position: vec4<f32>, @invariant @builtin(position) position: vec4<f32>,
@location(0) world: vec3<f32>, @location(0) world: vec3<f32>,
@location(1) normal: vec3<f32>, @location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>, @location(2) uv: vec2<f32>,
@@ -175,7 +172,7 @@ struct VertexOutput {
@group(0) @binding(4) var<storage, read> scales: array<vec4<f32>>; @group(0) @binding(4) var<storage, read> scales: array<vec4<f32>>;
@group(0) @binding(5) var<storage, read> meshInfo: array<u32>; @group(0) @binding(5) var<storage, read> meshInfo: array<u32>;
@group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>; @group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>;
@group(0) @binding(7) var<storage, read> cameras: array<vec4<f32>>; @group(0) @binding(7) var<storage, read> cameraMatrices: array<vec4<f32>>;
${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""} ${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""}
${baseTexture ? "@group(1) @binding(1) var baseTexture: texture_2d<f32>;" : ""} ${baseTexture ? "@group(1) @binding(1) var baseTexture: texture_2d<f32>;" : ""}
${materialTexture ? "@group(1) @binding(2) var materialTexture: texture_2d<f32>;" : ""} ${materialTexture ? "@group(1) @binding(2) var materialTexture: texture_2d<f32>;" : ""}
@@ -197,18 +194,14 @@ fn vertex(
let scale = scales[instance].xyz; let scale = scales[instance].xyz;
let world = rotate(quaternions[instance], point * scale) + positions[instance].xyz; let world = rotate(quaternions[instance], point * scale) + positions[instance].xyz;
var clip = vec4<f32>(world, 1.0); var clip = vec4<f32>(world, 1.0);
if (cameras[2].w != 0.0) { if (cameraMatrices[4].w != 0.0) {
let cameraNode = u32(cameras[1].x); let homogeneous = vec4(world, 1.0);
let inverse = vec4<f32>(-quaternions[cameraNode].xyz, quaternions[cameraNode].w); clip = vec4(
let view = rotate(inverse, world - positions[cameraNode].xyz); dot(cameraMatrices[0], homogeneous),
if (cameras[1].y == 1.0) { dot(cameraMatrices[1], homogeneous),
let size = max(cameras[1].z, 0.0001); dot(cameraMatrices[2], homogeneous),
clip = vec4<f32>(view.x / (size * cameras[0].y * 0.5), view.y / (size * 0.5), -view.z / cameras[0].w, 1.0); dot(cameraMatrices[3], homogeneous),
} else { );
let focal = 1.0 / tan(cameras[0].x * 0.5);
let depth = (-view.z * cameras[0].w - cameras[0].z * cameras[0].w) / (cameras[0].w - cameras[0].z);
clip = vec4<f32>(view.x * focal / cameras[0].y, view.y * focal, depth, -view.z);
}
} }
var output: VertexOutput; var output: VertexOutput;
output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), clip, meshInfo[instance * 4u + 2u] != 0u); output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), clip, meshInfo[instance * 4u + 2u] != 0u);
@@ -234,8 +227,7 @@ fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
let roughness = clamp(properties.y * ${materialTexture ? "packedMaterial.g" : "1.0"}, 0.04, 1.0); let roughness = clamp(properties.y * ${materialTexture ? "packedMaterial.g" : "1.0"}, 0.04, 1.0);
var normal = normalize(input.normal); var normal = normalize(input.normal);
${normalTexture ? "let tangent = normalize(input.tangent.xyz); let bitangent = normalize(cross(normal, tangent)) * input.tangent.w; let mapped = textureSample(normalTexture, materialSampler, input.uv).xyz * 2.0 - 1.0; normal = normalize(mat3x3<f32>(tangent, bitangent, normal) * vec3(mapped.xy * extra.y, mapped.z));" : ""} ${normalTexture ? "let tangent = normalize(input.tangent.xyz); let bitangent = normalize(cross(normal, tangent)) * input.tangent.w; let mapped = textureSample(normalTexture, materialSampler, input.uv).xyz * 2.0 - 1.0; normal = normalize(mat3x3<f32>(tangent, bitangent, normal) * vec3(mapped.xy * extra.y, mapped.z));" : ""}
let cameraNode = u32(cameras[1].x); let view = normalize(cameraMatrices[4].xyz - input.world);
let view = normalize(positions[cameraNode].xyz - input.world);
let lightDirection = normalize(vec3<f32>(0.4, 0.7, 0.6)); let lightDirection = normalize(vec3<f32>(0.4, 0.7, 0.6));
let halfVector = normalize(lightDirection + view); let halfVector = normalize(lightDirection + view);
let nDotL = max(dot(normal, lightDirection), 0.0); let nDotL = max(dot(normal, lightDirection), 0.0);
@@ -683,7 +675,6 @@ export class Scene {
]) ])
addBuffer({ id, array, usage: ["storage"] }); addBuffer({ id, array, usage: ["storage"] });
addBuffer({ id: "accent", array: "sceneAccent", usage: ["uniform"] }); addBuffer({ id: "accent", array: "sceneAccent", usage: ["uniform"] });
computePipelines.push({ computePipelines.push({
id: "cluster-lights", id: "cluster-lights",
code: clusterShader, code: clusterShader,
@@ -747,6 +738,7 @@ export class Scene {
id: "material-linear", id: "material-linear",
magFilter: "linear", magFilter: "linear",
minFilter: "linear", minFilter: "linear",
mipmapFilter: "linear",
addressModeU: "repeat", addressModeU: "repeat",
addressModeV: "repeat", addressModeV: "repeat",
}); });
@@ -778,7 +770,7 @@ export class Scene {
["node-scales", "nodeScales"], ["node-scales", "nodeScales"],
["mesh-info", "meshInfo"], ["mesh-info", "meshInfo"],
["materials", "materials"], ["materials", "materials"],
["cameras", "cameras"], ["camera-matrices", "cameraMatrices"],
]) ])
addBuffer({ id, array, usage: ["storage"] }); addBuffer({ id, array, usage: ["storage"] });
const forwardPipelines = new Set<string>(); const forwardPipelines = new Set<string>();
@@ -841,7 +833,9 @@ export class Scene {
const material = const material =
draw.material ?? Number(this.array("meshInfo").row(mesh.id)[1]); draw.material ?? Number(this.array("meshInfo").row(mesh.id)[1]);
if (mesh.id > 65535 || material > 65534) if (mesh.id > 65535 || material > 65534)
throw new RangeError("Scene handle limit"); throw new RangeError(
`Scene handle limit: mesh ${mesh.id}, material ${material}`,
);
const pointers = this.array("materialTextures").row(material); const pointers = this.array("materialTextures").row(material);
const texture = (lane: number) => const texture = (lane: number) =>
pointers[lane] pointers[lane]
@@ -955,7 +949,7 @@ export class Scene {
"node-scales", "node-scales",
"mesh-info", "mesh-info",
"materials", "materials",
"cameras", "camera-matrices",
].map((resource, binding) => ({ ].map((resource, binding) => ({
group: 0, group: 0,
binding, binding,
+80 -6
View File
@@ -1,6 +1,22 @@
import { Node, type NodeOptions } from "../Node"; import { Node, type NodeOptions } from "../Node";
import type { Scene } from "../Scene"; import type { Scene } from "../Scene";
function rotate(q: ArrayLike<number>, value: number[]) {
const [x, y, z] = value;
const tx = 2 * (q[1] * z - q[2] * y);
const ty = 2 * (q[2] * x - q[0] * z);
const tz = 2 * (q[0] * y - q[1] * x);
return [
x + q[3] * tx + q[1] * tz - q[2] * ty,
y + q[3] * ty + q[2] * tx - q[0] * tz,
z + q[3] * tz + q[0] * ty - q[1] * tx,
];
}
function dot(left: number[], right: ArrayLike<number>) {
return left[0] * right[0] + left[1] * right[1] + left[2] * right[2];
}
export type CameraOptions = NodeOptions & { export type CameraOptions = NodeOptions & {
fov?: number; fov?: number;
near?: number; near?: number;
@@ -24,6 +40,7 @@ export class Camera extends Node {
const nodeReady = this.ready; const nodeReady = this.ready;
this.ready = nodeReady.then(async () => { this.ready = nodeReady.then(async () => {
this.cameraId = await scene.core.allocateObject("cameras"); this.cameraId = await scene.core.allocateObject("cameras");
await scene.ensureRows("cameraMatrices", this.cameraId + 1, 80, "f32");
const row = scene.array("cameras").row(this.cameraId); const row = scene.array("cameras").row(this.cameraId);
row.set([ row.set([
options.fov ?? Math.PI / 3, options.fov ?? Math.PI / 3,
@@ -39,6 +56,7 @@ export class Camera extends Node {
options.sensorWidth ?? 36, options.sensorWidth ?? 36,
1, 1,
]); ]);
this.refreshMatrix();
return this; return this;
}); });
} }
@@ -49,17 +67,20 @@ export class Camera extends Node {
} }
get fov() { return this.cameraRow()[0]; } get fov() { return this.cameraRow()[0]; }
set fov(value: number) { this.cameraRow()[0] = value; } set fov(value: number) { this.cameraRow()[0] = value; this.refreshMatrix(); }
get aspect() { return this.cameraRow()[1]; } get aspect() { return this.cameraRow()[1]; }
set aspect(value: number) { this.cameraRow()[1] = value; } set aspect(value: number) { this.cameraRow()[1] = value; this.refreshMatrix(); }
get near() { return this.cameraRow()[2]; } get near() { return this.cameraRow()[2]; }
set near(value: number) { this.cameraRow()[2] = value; } set near(value: number) { this.cameraRow()[2] = value; this.refreshMatrix(); }
get far() { return this.cameraRow()[3]; } get far() { return this.cameraRow()[3]; }
set far(value: number) { this.cameraRow()[3] = value; } set far(value: number) { this.cameraRow()[3] = value; this.refreshMatrix(); }
get projection() { return this.cameraRow()[5] === 1 ? "orthographic" : "perspective"; } get projection() { return this.cameraRow()[5] === 1 ? "orthographic" : "perspective"; }
set projection(value: "perspective" | "orthographic") { this.cameraRow()[5] = value === "orthographic" ? 1 : 0; } set projection(value: "perspective" | "orthographic") {
this.cameraRow()[5] = value === "orthographic" ? 1 : 0;
this.refreshMatrix();
}
get orthoSize() { return this.cameraRow()[6]; } get orthoSize() { return this.cameraRow()[6]; }
set orthoSize(value: number) { this.cameraRow()[6] = value; } set orthoSize(value: number) { this.cameraRow()[6] = value; this.refreshMatrix(); }
get focalLength() { return this.cameraRow()[7]; } get focalLength() { return this.cameraRow()[7]; }
set focalLength(value: number) { this.cameraRow()[7] = value; } set focalLength(value: number) { this.cameraRow()[7] = value; }
get aperture() { return this.cameraRow()[8]; } get aperture() { return this.cameraRow()[8]; }
@@ -69,6 +90,18 @@ export class Camera extends Node {
get sensorWidth() { return this.cameraRow()[10]; } get sensorWidth() { return this.cameraRow()[10]; }
set sensorWidth(value: number) { this.cameraRow()[10] = value; } set sensorWidth(value: number) { this.cameraRow()[10] = value; }
override get position(): Float32Array { return super.position; }
override set position(value: ArrayLike<number>) {
super.position = value;
this.refreshMatrix();
}
override get quaternion(): Float32Array { return super.quaternion; }
override set quaternion(value: ArrayLike<number>) {
super.quaternion = value;
this.refreshMatrix();
}
lookAt(target: ArrayLike<number>) { lookAt(target: ArrayLike<number>) {
if (target.length !== 3) throw new RangeError("camera target"); if (target.length !== 3) throw new RangeError("camera target");
const position = this.position; const position = this.position;
@@ -86,6 +119,47 @@ export class Camera extends Node {
return this; return this;
} }
protected refreshMatrix() {
if (this.id < 0 || this.cameraId < 0) return;
const camera = this.cameraRow();
const position = this.position;
const quaternion = this.quaternion;
const right = rotate(quaternion, [1, 0, 0]);
const up = rotate(quaternion, [0, 1, 0]);
const forward = rotate(quaternion, [0, 0, 1]);
let rows: number[][];
if (camera[5] === 1) {
const size = Math.max(camera[6], 0.0001);
const x = 2 / (size * camera[1]);
const y = 2 / size;
const z = -1 / camera[3];
rows = [
[...right.map((value) => value * x), -dot(right, position) * x],
[...up.map((value) => value * y), -dot(up, position) * y],
[...forward.map((value) => value * z), -dot(forward, position) * z],
[0, 0, 0, 1],
];
} else {
const focal = 1 / Math.tan(camera[0] * 0.5);
const x = focal / camera[1];
const z = -camera[3] / (camera[3] - camera[2]);
const translation = (-camera[2] * camera[3]) / (camera[3] - camera[2]);
rows = [
[...right.map((value) => value * x), -dot(right, position) * x],
[...up.map((value) => value * focal), -dot(up, position) * focal],
[
...forward.map((value) => value * z),
-dot(forward, position) * z + translation,
],
[...forward.map((value) => -value), dot(forward, position)],
];
}
this.scene
.array("cameraMatrices")
.row(this.cameraId)
.set([...rows.flat(), ...position, camera[11]]);
}
override async dispose() { override async dispose() {
await this.ready; await this.ready;
if (this.#cameraDisposed) return; if (this.#cameraDisposed) return;
+1
View File
@@ -91,6 +91,7 @@ export class FreeCamera extends Camera {
this.position[0] += (x * Math.cos(yaw) + z * Math.sin(yaw)) * speed; this.position[0] += (x * Math.cos(yaw) + z * Math.sin(yaw)) * speed;
this.position[1] += y * speed; this.position[1] += y * speed;
this.position[2] += (x * -Math.sin(yaw) + z * Math.cos(yaw)) * speed; this.position[2] += (x * -Math.sin(yaw) + z * Math.cos(yaw)) * speed;
this.refreshMatrix();
this.#frame = requestAnimationFrame(this.#update); this.#frame = requestAnimationFrame(this.#update);
}; };
+22 -2
View File
@@ -5,6 +5,7 @@ export type TextureOptions = {
size?: [number | "canvas", number | "canvas", number?]; size?: [number | "canvas", number | "canvas", number?];
format?: string; format?: string;
usage?: string[]; usage?: string[];
mipmaps?: boolean;
transient?: boolean; transient?: boolean;
}; };
@@ -29,6 +30,10 @@ export class Texture {
typeof options.source === "string" typeof options.source === "string"
? await createImageBitmap(await (await fetch(options.source)).blob()) ? await createImageBitmap(await (await fetch(options.source)).blob())
: options.source; : options.source;
const mipLevelCount =
image instanceof ImageBitmap && options.mipmaps !== false
? Math.floor(Math.log2(Math.max(image.width, image.height))) + 1
: 1;
const registration = scene.registerTexture({ const registration = scene.registerTexture({
id: resource, id: resource,
source: image, source: image,
@@ -38,6 +43,7 @@ export class Texture {
? [image.width, image.height, 1] ? [image.width, image.height, 1]
: [1, 1, 1]), : [1, 1, 1]),
format: options.format ?? "rgba8unorm", format: options.format ?? "rgba8unorm",
mipLevelCount,
usage: [ usage: [
...new Set([ ...new Set([
...(options.usage ?? ["copyDst"]), ...(options.usage ?? ["copyDst"]),
@@ -49,8 +55,22 @@ export class Texture {
}); });
this.id = registration.number; this.id = registration.number;
await registration.ready; await registration.ready;
if (image instanceof ImageBitmap) if (image instanceof ImageBitmap) {
await scene.core.uploadTexture(resource, image); const levels = [image];
for (let level = 1; level < mipLevelCount; level++)
levels.push(
await createImageBitmap(image, {
resizeWidth: Math.max(1, image.width >> level),
resizeHeight: Math.max(1, image.height >> level),
resizeQuality: "high",
}),
);
await Promise.all(
levels.map((level, index) =>
scene.core.uploadTexture(resource, level, index),
),
);
}
})(); })();
} }
+12 -3
View File
@@ -119,11 +119,20 @@ export class YawnCore {
return this.#request("switch-loadout", { id }); return this.#request("switch-loadout", { id });
} }
async uploadTexture(name, image) { async uploadTexture(name, image, mipLevel = 0) {
await this.ready; await this.ready;
if (typeof name !== "string" || !(image instanceof ImageBitmap)) if (
typeof name !== "string" ||
!(image instanceof ImageBitmap) ||
!Number.isInteger(mipLevel) ||
mipLevel < 0
)
throw new TypeError("TEXTURE_SOURCE"); throw new TypeError("TEXTURE_SOURCE");
return this.#request("upload-texture", { name, image }, [image]); return this.#request(
"upload-texture",
{ name, image, mipLevel },
[image],
);
} }
async deleteTexture(name) { async deleteTexture(name) {
+7 -2
View File
@@ -174,14 +174,19 @@ impl Core {
.map_err(|error| JsError::new(&error)) .map_err(|error| JsError::new(&error))
} }
pub fn upload_texture(&self, name: String, image: web_sys::ImageBitmap) -> Result<(), JsError> { pub fn upload_texture(
&self,
name: String,
mip_level: u32,
image: web_sys::ImageBitmap,
) -> Result<(), JsError> {
let gpu = self.gpu.borrow(); let gpu = self.gpu.borrow();
let gpu = gpu let gpu = gpu
.as_ref() .as_ref()
.ok_or_else(|| JsError::new("WEBGPU_UNINITIALIZED"))?; .ok_or_else(|| JsError::new("WEBGPU_UNINITIALIZED"))?;
self.store self.store
.borrow_mut() .borrow_mut()
.upload_texture(name, image, gpu) .upload_texture(name, mip_level, image, gpu)
.map_err(|error| JsError::new(&error)) .map_err(|error| JsError::new(&error))
} }
+54 -19
View File
@@ -1,4 +1,4 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::num::NonZeroU64; use std::num::NonZeroU64;
use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc; use std::sync::Arc;
@@ -46,7 +46,7 @@ pub struct GpuTexture {
pub texture: wgpu::Texture, pub texture: wgpu::Texture,
pub view: wgpu::TextureView, pub view: wgpu::TextureView,
key: String, key: String,
uploaded: bool, uploaded_mips: HashSet<u32>,
} }
pub enum GpuPass { pub enum GpuPass {
@@ -124,7 +124,7 @@ impl GpuResources {
view: texture.create_view(&wgpu::TextureViewDescriptor::default()), view: texture.create_view(&wgpu::TextureViewDescriptor::default()),
texture, texture,
key, key,
uploaded: false, uploaded_mips: HashSet::new(),
}); });
physical_slots.insert(source.slot, physical); physical_slots.insert(source.slot, physical);
physical physical
@@ -211,21 +211,29 @@ impl GpuResources {
}; };
for execution in &graph.executions { for execution in &graph.executions {
let compiled = match execution { let compiled = match execution {
Execution::Render(passes) => GpuPass::Render { Execution::Render(passes) => {
let (bundle, draws) = resources.render_bundle(graph, passes, gpu)?;
GpuPass::Render {
label: if passes.len() == 1 { label: if passes.len() == 1 {
graph.passes[passes[0]].id.clone() graph.passes[passes[0]].id.clone()
} else if passes } else if passes
.iter() .iter()
.all(|index| graph.passes[*index].id.starts_with("forward-")) .all(|index| graph.passes[*index].id.starts_with("forward-"))
{ {
format!("Forward ({} draws)", passes.len()) format!("Forward ({draws} draws)")
} else if passes
.iter()
.all(|index| graph.passes[*index].id.starts_with("depth-"))
{
format!("Depth ({draws} draws)")
} else { } else {
format!("Render ({} draws)", passes.len()) format!("Render ({draws} draws)")
}, },
first: passes[0], first: passes[0],
last: *passes.last().unwrap(), last: *passes.last().unwrap(),
bundle: resources.render_bundle(graph, passes, gpu)?, bundle,
}, }
}
Execution::Compute(index) => { Execution::Compute(index) => {
let pass = &graph.passes[*index]; let pass = &graph.passes[*index];
let pipeline = resources let pipeline = resources
@@ -261,6 +269,7 @@ impl GpuResources {
pub fn upload_texture( pub fn upload_texture(
&mut self, &mut self,
id: &str, id: &str,
mip_level: u32,
image: &web_sys::ImageBitmap, image: &web_sys::ImageBitmap,
gpu: &Wgpu, gpu: &Wgpu,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -277,7 +286,7 @@ impl GpuResources {
}, },
wgpu::TexelCopyTextureInfo { wgpu::TexelCopyTextureInfo {
texture: &texture.texture, texture: &texture.texture,
mip_level: 0, mip_level,
origin: wgpu::Origin3d::ZERO, origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All, aspect: wgpu::TextureAspect::All,
} }
@@ -288,15 +297,15 @@ impl GpuResources {
depth_or_array_layers: 1, depth_or_array_layers: 1,
}, },
); );
texture.uploaded = true; texture.uploaded_mips.insert(mip_level);
Ok(()) Ok(())
} }
pub fn needs_upload(&self, id: &str) -> bool { pub fn needs_upload(&self, id: &str, mip_level: u32) -> bool {
self.texture_slots self.texture_slots
.get(id) .get(id)
.and_then(|slot| self.textures.get(*slot)) .and_then(|slot| self.textures.get(*slot))
.is_some_and(|texture| !texture.uploaded) .is_some_and(|texture| !texture.uploaded_mips.contains(&mip_level))
} }
pub fn render_timestamps(&self, pass: usize) -> Option<wgpu::RenderPassTimestampWrites<'_>> { pub fn render_timestamps(&self, pass: usize) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
@@ -396,7 +405,7 @@ impl GpuResources {
graph: &RenderGraph, graph: &RenderGraph,
passes: &[usize], passes: &[usize],
gpu: &Wgpu, gpu: &Wgpu,
) -> Result<wgpu::RenderBundle, String> { ) -> Result<(wgpu::RenderBundle, usize), String> {
let pass = &graph.passes[passes[0]]; let pass = &graph.passes[passes[0]];
let declaration = graph let declaration = graph
.pipelines .pipelines
@@ -431,8 +440,10 @@ impl GpuResources {
}); });
let mut previous_pipeline = None; let mut previous_pipeline = None;
let mut previous_bindings: Option<&[Binding]> = None; let mut previous_bindings: Option<&[Binding]> = None;
for index in passes { let mut draws = 0;
let pass = &graph.passes[*index]; let mut at = 0;
while at < passes.len() {
let pass = &graph.passes[passes[at]];
let pipeline = self let pipeline = self
.render_pipelines .render_pipelines
.get(&pass.pipeline) .get(&pass.pipeline)
@@ -468,10 +479,17 @@ impl GpuResources {
buffer.slice(binding.offset..), buffer.slice(binding.offset..),
parse(&binding.format, "GRAPH_INDEX_FORMAT")?, parse(&binding.format, "GRAPH_INDEX_FORMAT")?,
); );
let mut instances = pass.draw.instances;
while at + 1 < passes.len()
&& can_instance(pass, &graph.passes[passes[at + 1]], instances)
{
at += 1;
instances += graph.passes[passes[at]].draw.instances;
}
encoder.draw_indexed( encoder.draw_indexed(
pass.draw.first_index..pass.draw.first_index + pass.draw.indices, pass.draw.first_index..pass.draw.first_index + pass.draw.indices,
pass.draw.base_vertex, pass.draw.base_vertex,
pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances, pass.draw.first_instance..pass.draw.first_instance + instances,
); );
} else { } else {
encoder.draw( encoder.draw(
@@ -479,10 +497,15 @@ impl GpuResources {
pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances, pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances,
); );
} }
draws += 1;
at += 1;
} }
Ok(encoder.finish(&wgpu::RenderBundleDescriptor { Ok((
encoder.finish(&wgpu::RenderBundleDescriptor {
label: Some(&pass.id), label: Some(&pass.id),
})) }),
draws,
))
} }
fn bind_groups( fn bind_groups(
@@ -531,6 +554,18 @@ impl GpuResources {
} }
} }
fn can_instance(first: &Pass, next: &Pass, instances: u32) -> bool {
first.pipeline == next.pipeline
&& first.bindings == next.bindings
&& first.vertex_buffers == next.vertex_buffers
&& first.index_buffer == next.index_buffer
&& first.draw.indices != 0
&& first.draw.indices == next.draw.indices
&& first.draw.first_index == next.draw.first_index
&& first.draw.base_vertex == next.draw.base_vertex
&& first.draw.first_instance + instances == next.draw.first_instance
}
impl GpuPass { impl GpuPass {
fn label(&self) -> &str { fn label(&self) -> &str {
match self { match self {
@@ -621,7 +656,7 @@ fn create_render_pipeline(
primitive: primitive(&source.primitive)?, primitive: primitive(&source.primitive)?,
depth_stencil: depth_stencil(&source.depth_stencil)?, depth_stencil: depth_stencil(&source.depth_stencil)?,
multisample: multisample(&source.multisample)?, multisample: multisample(&source.multisample)?,
fragment: Some(wgpu::FragmentState { fragment: (!targets.is_empty()).then_some(wgpu::FragmentState {
module: &module, module: &module,
entry_point: Some(&source.fragment.entry), entry_point: Some(&source.fragment.entry),
compilation_options: Default::default(), compilation_options: Default::default(),
+3 -3
View File
@@ -229,7 +229,7 @@ pub struct DepthAttachment {
pub store: String, pub store: String,
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize, PartialEq, Eq)]
pub struct VertexBinding { pub struct VertexBinding {
#[serde(default)] #[serde(default)]
pub slot: u32, pub slot: u32,
@@ -238,7 +238,7 @@ pub struct VertexBinding {
pub offset: u64, pub offset: u64,
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize, PartialEq, Eq)]
pub struct IndexBinding { pub struct IndexBinding {
pub resource: String, pub resource: String,
#[serde(default = "index_format")] #[serde(default = "index_format")]
@@ -247,7 +247,7 @@ pub struct IndexBinding {
pub offset: u64, pub offset: u64,
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Draw { pub struct Draw {
#[serde(default = "three")] #[serde(default = "three")]
+20 -10
View File
@@ -13,7 +13,7 @@ pub struct Loadout {
#[derive(Default)] #[derive(Default)]
pub struct Store { pub struct Store {
graphs: HashMap<String, RenderGraph>, graphs: HashMap<String, RenderGraph>,
texture_sources: HashMap<String, web_sys::ImageBitmap>, texture_sources: HashMap<String, HashMap<u32, web_sys::ImageBitmap>>,
active: Option<Loadout>, active: Option<Loadout>,
} }
@@ -32,9 +32,11 @@ impl Store {
data, data,
self.active.as_ref().map(|active| &active.resources), self.active.as_ref().map(|active| &active.resources),
)?; )?;
for (name, image) in &self.texture_sources { for (name, levels) in &self.texture_sources {
if resources.needs_upload(name) { for (mip_level, image) in levels {
resources.upload_texture(name, image, gpu)?; if resources.needs_upload(name, *mip_level) {
resources.upload_texture(name, *mip_level, image, gpu)?;
}
} }
} }
self.active = Some(Loadout { graph, resources }); self.active = Some(Loadout { graph, resources });
@@ -44,25 +46,31 @@ impl Store {
pub fn upload_texture( pub fn upload_texture(
&mut self, &mut self,
name: String, name: String,
mip_level: u32,
image: web_sys::ImageBitmap, image: web_sys::ImageBitmap,
gpu: &Wgpu, gpu: &Wgpu,
) -> Result<(), String> { ) -> Result<(), String> {
if let Some(active) = &mut self.active { if let Some(active) = &mut self.active {
if active.resources.texture_slots.contains_key(&name) { if active.resources.texture_slots.contains_key(&name) {
active.resources.upload_texture(&name, &image, gpu)?; active
.resources
.upload_texture(&name, mip_level, &image, gpu)?;
} }
} }
if let Some(previous) = self.texture_sources.insert(name, image) { let levels = self.texture_sources.entry(name).or_default();
if let Some(previous) = levels.insert(mip_level, image) {
previous.close(); previous.close();
} }
Ok(()) Ok(())
} }
pub fn delete_texture(&mut self, name: &str) { pub fn delete_texture(&mut self, name: &str) {
if let Some(image) = self.texture_sources.remove(name) { if let Some(levels) = self.texture_sources.remove(name) {
for image in levels.into_values() {
image.close(); image.close();
} }
} }
}
pub fn active_mut(&mut self) -> Option<&mut Loadout> { pub fn active_mut(&mut self) -> Option<&mut Loadout> {
self.active.as_mut() self.active.as_mut()
@@ -102,9 +110,11 @@ impl Store {
data, data,
self.active.as_ref().map(|active| &active.resources), self.active.as_ref().map(|active| &active.resources),
)?; )?;
for (name, image) in &self.texture_sources { for (name, levels) in &self.texture_sources {
if resources.needs_upload(name) { for (mip_level, image) in levels {
resources.upload_texture(name, image, gpu)?; if resources.needs_upload(name, *mip_level) {
resources.upload_texture(name, *mip_level, image, gpu)?;
}
} }
} }
self.active = Some(Loadout { graph, resources }); self.active = Some(Loadout { graph, resources });
+6
View File
@@ -110,6 +110,7 @@ impl RenderLoop {
while !submission.completed.load(Ordering::Acquire) { while !submission.completed.load(Ordering::Acquire) {
TimeoutFuture::new(0).await; TimeoutFuture::new(0).await;
} }
let wall_milliseconds = js_sys::Date::now() - started;
if let Some(profile) = submission.profile { if let Some(profile) = submission.profile {
while profile.state() == 0 { while profile.state() == 0 {
TimeoutFuture::new(0).await; TimeoutFuture::new(0).await;
@@ -129,10 +130,15 @@ impl RenderLoop {
.iter() .iter()
.filter_map(|pass| pass["milliseconds"].as_f64()) .filter_map(|pass| pass["milliseconds"].as_f64())
.sum::<f64>(); .sum::<f64>();
let gpu = gpu.borrow();
let gpu = gpu.as_ref().unwrap();
*control.profile.borrow_mut() = Some( *control.profile.borrow_mut() = Some(
serde_json::json!({ serde_json::json!({
"frame": frame, "frame": frame,
"milliseconds": milliseconds, "milliseconds": milliseconds,
"wallMilliseconds": wall_milliseconds,
"adapter": gpu.adapter,
"canvas": { "width": gpu.width, "height": gpu.height },
"passes": passes, "passes": passes,
}) })
.to_string(), .to_string(),
+13 -1
View File
@@ -8,6 +8,7 @@ pub struct Wgpu {
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
pub timestamp_queries: bool, pub timestamp_queries: bool,
pub adapter: String,
} }
impl Wgpu { impl Wgpu {
@@ -27,11 +28,21 @@ impl Wgpu {
let adapter = instance let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions { .request_adapter(&wgpu::RequestAdapterOptions {
compatible_surface: Some(&surface), compatible_surface: Some(&surface),
..Default::default() power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
}) })
.await .await
.map_err(|_| "WEBGPU_UNAVAILABLE")?; .map_err(|_| "WEBGPU_UNAVAILABLE")?;
let required_features = adapter.features() & wgpu::Features::TIMESTAMP_QUERY; let required_features = adapter.features() & wgpu::Features::TIMESTAMP_QUERY;
let info = adapter.get_info();
let adapter_name = if info.name.is_empty() {
format!("{:?} · {:?}", info.device_type, info.backend)
} else {
format!(
"{} · {:?} · {:?}",
info.name, info.device_type, info.backend
)
};
let (device, queue) = adapter let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor { .request_device(&wgpu::DeviceDescriptor {
required_features, required_features,
@@ -54,6 +65,7 @@ impl Wgpu {
width, width,
height, height,
timestamp_queries: required_features.contains(wgpu::Features::TIMESTAMP_QUERY), timestamp_queries: required_features.contains(wgpu::Features::TIMESTAMP_QUERY),
adapter: adapter_name,
}) })
} }
} }
+1 -1
View File
@@ -41,7 +41,7 @@ addEventListener("message", async ({ data: message }) => {
core.switch_loadout(message.id); core.switch_loadout(message.id);
break; break;
case "upload-texture": case "upload-texture":
core.upload_texture(message.name, message.image); core.upload_texture(message.name, message.mipLevel, message.image);
break; break;
case "delete-texture": case "delete-texture":
core.delete_texture(message.name); core.delete_texture(message.name);
+29
View File
@@ -28,6 +28,7 @@ const fps = ref(0);
const profilerOpen = ref(false); const profilerOpen = ref(false);
const profilerSupported = ref(null); const profilerSupported = ref(null);
const profile = ref(null); const profile = ref(null);
const adapterInfo = ref("");
let generation = 0; let generation = 0;
let current; let current;
let stopProfile; let stopProfile;
@@ -88,6 +89,20 @@ async function attachProfiler(runId = generation) {
profile.value = null; profile.value = null;
profilerSupported.value = null; profilerSupported.value = null;
if (!profilerOpen.value) return; if (!profilerOpen.value) return;
if (!adapterInfo.value) {
const adapter = await navigator.gpu?.requestAdapter({
powerPreference: "high-performance",
});
const info = adapter?.info;
adapterInfo.value = [
info?.vendor,
info?.architecture,
info?.device,
info?.description,
]
.filter(Boolean)
.join(" · ");
}
const core = current?.scene?.core ?? current?.core; const core = current?.scene?.core ?? current?.core;
if (!core?.onProfile || !core?.setProfiler) { if (!core?.onProfile || !core?.setProfiler) {
profilerSupported.value = false; profilerSupported.value = false;
@@ -273,6 +288,13 @@ onUnmounted(() => {
<strong>GPU passes</strong> <strong>GPU passes</strong>
<span v-if="profile">{{ profile.milliseconds.toFixed(2) }} ms</span> <span v-if="profile">{{ profile.milliseconds.toFixed(2) }} ms</span>
</div> </div>
<div v-if="profile" class="profiler-meta">
<span>{{ adapterInfo || profile.adapter }}</span>
<span>
{{ profile.canvas.width }}×{{ profile.canvas.height }} ·
{{ profile.wallMilliseconds.toFixed(2) }} ms wall
</span>
</div>
<p v-if="profilerSupported === false"> <p v-if="profilerSupported === false">
Timestamp queries are unavailable on this GPU. Timestamp queries are unavailable on this GPU.
</p> </p>
@@ -522,6 +544,13 @@ canvas {
color: #60a5fa; color: #60a5fa;
font-weight: 700; font-weight: 700;
} }
.profiler-meta {
display: grid;
gap: 3px;
margin: -5px 0 12px;
color: #64748b;
font-size: 10px;
}
.profiler p { .profiler p {
color: #94a3b8; color: #94a3b8;
} }
+94
View File
@@ -365,6 +365,99 @@ return {
}, },
};`; };`;
const benchmark = `import { ArcRotateCamera, Mesh, PBRMaterial, Scene, Texture } from "@yawn/handles";
const parameters = new URL(location.href).searchParams;
const draws = Math.max(1, Math.min(512, Number(parameters.get("draws")) || 138));
const grid = Math.max(1, Math.min(256, Number(parameters.get("grid")) || 128));
const overdraw = parameters.has("overdraw");
const columns = 12;
const positions = [];
const normals = [];
const uvs = [];
const indices = [];
for (let y = 0; y <= grid; y++) {
for (let x = 0; x <= grid; x++) {
positions.push(x / grid * 2 - 1, y / grid * 2 - 1, 0);
normals.push(0, 0, 1);
uvs.push(x / grid * 32, y / grid * 32);
}
}
for (let y = 0; y < grid; y++) {
for (let x = 0; x < grid; x++) {
const first = y * (grid + 1) + x;
indices.push(
first, first + 1, first + grid + 2,
first, first + grid + 2, first + grid + 1,
);
}
}
const pixels = new OffscreenCanvas(1024, 1024);
const context = pixels.getContext("2d");
for (let y = 0; y < 64; y++) {
for (let x = 0; x < 64; x++) {
context.fillStyle = (x + y) % 2 ? "#e2e8f0" : "#172554";
context.fillRect(x * 16, y * 16, 16, 16);
}
}
const scene = new Scene(canvas, { fps: 1000, hdr: true });
await scene.ready;
log("Benchmark core ready; preparing geometry…");
const camera = new ArcRotateCamera(scene, {
targetPosition: [0, 0, 0],
alpha: 0,
beta: Math.PI / 2,
radius: 3,
aspect: canvas.width / canvas.height,
});
await camera.ready;
log("Benchmark camera ready; compiling graph…");
let material;
let source;
const meshes = [];
await scene.batchGraphUpdates(async () => {
const texture = new Texture(scene, {
source: pixels.transferToImageBitmap(),
format: "rgba8unorm-srgb",
});
await texture.ready;
material = new PBRMaterial(scene, {
baseColorTexture: texture,
roughness: 0.45,
});
await material.ready;
source = new Mesh(scene, {
material,
vertexData: { positions, normals, uvs, indices },
});
await source.ready;
meshes.push(source);
for (let index = 1; index < draws; index++) meshes.push(source.clone());
await Promise.all(meshes.slice(1).map((mesh) => mesh.ready));
for (let index = 0; index < meshes.length; index++) {
const column = index % columns;
const row = Math.floor(index / columns);
meshes[index].position = overdraw
? [0, 0, index * 0.01]
: [
-1 + (column + 0.5) * 2 / columns,
1 - (row + 0.5) * 2 / columns,
0,
];
meshes[index].scale = overdraw
? [0.9, 0.9, 1]
: [0.9 / columns, 0.9 / columns, 1];
}
});
const triangles = draws * grid * grid * 2;
log(\`Deterministic \${overdraw ? "overdraw" : "geometry"} benchmark: \${draws} draws, \${triangles.toLocaleString()} triangles, 1024² minified texture.\`);
log("Open Profile and compare Forward GPU time, not page load time.");
return { scene, meshes, material, camera, dispose: () => scene.dispose() };`;
const core = `import { YawnCore } from "@yawn/core"; const core = `import { YawnCore } from "@yawn/core";
const encode = (value) => { const encode = (value) => {
@@ -403,5 +496,6 @@ export const playgrounds = {
compute: { title: "Compute pass", code: compute }, compute: { title: "Compute pass", code: compute },
post: { title: "HDR post processing", code: post }, post: { title: "HDR post processing", code: post },
importing: { title: "glTF import and BVH picking", code: importing }, importing: { title: "glTF import and BVH picking", code: importing },
benchmark: { title: "Forward benchmark", code: benchmark },
core: { title: "Direct core graph", code: core }, core: { title: "Direct core graph", code: core },
}; };
+2 -2
View File
@@ -1,6 +1,6 @@
# Cameras and controls # Cameras and controls
Every camera allocates a generic `cameras` slot and a transform node. Projection, lens, controller state, and transforms are direct SAB rows after construction. Every camera allocates generic `cameras`, `cameraMatrices`, and transform rows. Camera handles precompute the view-projection matrix when lens or transform values change, so the forward vertex shader performs four dot products instead of rebuilding the camera projection for every vertex.
## Shared lens and projection controls ## Shared lens and projection controls
@@ -72,7 +72,7 @@ follow.stop();
follow.start(); follow.start();
``` ```
The input and follow loops never post camera updates to core: they read and mutate the same camera, position, and quaternion rows that any other worker can use. The input and follow loops never post camera updates to core: they mutate the position, quaternion, camera, and derived matrix rows directly in shared memory.
<Playground example="cameras" /> <Playground example="cameras" />
+3 -1
View File
@@ -56,7 +56,9 @@ await core.setProfiler(false);
stop(); stop();
``` ```
`new YawnCore(canvas, { debug: true })` enables the same timestamp-query mode at startup. Timings describe the physical compute and render passes produced by graph compilation, so 138 compatible draws appear as one bundled forward pass. The fullscreen playgrounds **Profile** button shows the stream in a toggleable sidebar. `new YawnCore(canvas, { debug: true })` enables the same timestamp-query mode at startup. Timings describe physical GPU passes and actual compiled draw counts; compatible indexed draws over consecutive instances collapse into one command. The sidebar also reports canvas size and wall-clock completion time.
The saved **Forward benchmark** playground renders 138 logical objects and 4.5 million triangles. Add `&grid=32` to lower geometry density or `&overdraw=1` to stack the objects while diagnosing depth and fragment cost.
<Playground example="core" /> <Playground example="core" />
+1 -1
View File
@@ -30,7 +30,7 @@ await albedo.ready;
const textured = new PBRMaterial(scene, { baseColorTexture: albedo }); const textured = new PBRMaterial(scene, { baseColorTexture: albedo });
``` ```
Creating or removing a `Texture` rebuilds the single graph loadout so the GPU resource is allocated up front. The addon decodes URLs outside core, then transfers an `ImageBitmap` to the render worker; compatible loadout rebuilds reuse the allocated GPU texture instead of uploading it again. Creating or removing a `Texture` rebuilds the single graph loadout so the GPU resource is allocated up front. The addon generates and transfers a complete mip chain by default; pass `mipmaps: false` only for data that must remain single-level. Compatible loadout rebuilds reuse the allocated GPU texture without uploading it again.
## Custom WGSL ## Custom WGSL