From abfa4284645588edba36d96c9f4012b2b6642ff9 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 20 Aug 2026 14:36:51 +0000 Subject: [PATCH] Optimize forward rendering and add benchmark Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae Co-authored-by: Heaust Azure --- addons/handles/src/ComputePass.ts | 2 + addons/handles/src/Scene.ts | 62 ++++++++-------- addons/handles/src/camera/Camera.ts | 86 ++++++++++++++++++++-- addons/handles/src/camera/FreeCamera.ts | 1 + addons/handles/src/materials/Texture.ts | 24 ++++++- core/index.js | 15 +++- core/lib.rs | 9 ++- core/render_graph/gpu_resource.rs | 95 +++++++++++++++++-------- core/render_graph/render_graph.rs | 6 +- core/render_graph/store.rs | 32 ++++++--- core/renderer/render.rs | 6 ++ core/renderer/wgpu.rs | 14 +++- core/worker.js | 2 +- docs/.vitepress/Playground.vue | 29 ++++++++ docs/.vitepress/playgrounds.js | 94 ++++++++++++++++++++++++ docs/guide/cameras.md | 4 +- docs/guide/core.md | 4 +- docs/guide/materials.md | 2 +- 18 files changed, 390 insertions(+), 97 deletions(-) diff --git a/addons/handles/src/ComputePass.ts b/addons/handles/src/ComputePass.ts index ebefc86..42010f1 100644 --- a/addons/handles/src/ComputePass.ts +++ b/addons/handles/src/ComputePass.ts @@ -12,6 +12,7 @@ export type GraphTexture = { format?: string; size?: [number | "canvas", number | "canvas", number?]; usage?: string[]; + mipLevelCount?: number; transient?: boolean; }; @@ -19,6 +20,7 @@ export type GraphSampler = { id: string; magFilter?: "nearest" | "linear"; minFilter?: "nearest" | "linear"; + mipmapFilter?: "nearest" | "linear"; addressModeU?: "clamp-to-edge" | "repeat" | "mirror-repeat"; addressModeV?: "clamp-to-edge" | "repeat" | "mirror-repeat"; }; diff --git a/addons/handles/src/Scene.ts b/addons/handles/src/Scene.ts index 6fc00e6..efbccbd 100644 --- a/addons/handles/src/Scene.ts +++ b/addons/handles/src/Scene.ts @@ -38,6 +38,7 @@ const rows = [ ["meshInfo", 16, "u32"], ["bounds", 32, "f32"], ["cameras", 80, "f32"], + ["cameraMatrices", 80, "f32"], ["materials", 48, "f32"], ["materialTextures", 32, "u32"], ["pointLights", 32, "f32"], @@ -94,7 +95,7 @@ fn main(@builtin(global_invocation_id) id: vec3) { const basicForwardShader = /* wgsl */ ` struct Accent { color: vec4 } struct VertexOutput { - @builtin(position) position: vec4, + @invariant @builtin(position) position: vec4, @location(0) normal: vec3, @location(1) @interpolate(flat) mesh: u32, @location(2) @interpolate(flat) material: u32, @@ -107,7 +108,7 @@ struct VertexOutput { @group(0) @binding(4) var scales: array>; @group(0) @binding(5) var meshInfo: array; @group(0) @binding(6) var materials: array>; -@group(0) @binding(7) var cameras: array>; +@group(0) @binding(7) var cameraMatrices: array>; fn rotate(q: vec4, value: vec3) -> vec3 { return value + 2.0 * cross(q.xyz, cross(q.xyz, value) + q.w * value); @@ -119,18 +120,14 @@ fn vertex(@location(0) point: vec3, @builtin(instance_index) packed: u32) - let visible = meshInfo[instance * 4u + 2u]; let transformed = rotate(quaternions[instance], point * scales[instance].xyz) + positions[instance].xyz; var clip = vec4(transformed, 1.0); - if (cameras[2].w != 0.0) { - let cameraNode = u32(cameras[1].x); - let inverse = vec4(-quaternions[cameraNode].xyz, quaternions[cameraNode].w); - let view = rotate(inverse, transformed - positions[cameraNode].xyz); - if (cameras[1].y == 1.0) { - let size = max(cameras[1].z, 0.0001); - clip = vec4(view.x / (size * cameras[0].y * 0.5), view.y / (size * 0.5), -view.z / cameras[0].w, 1.0); - } 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(view.x * focal / cameras[0].y, view.y * focal, depth, -view.z); - } + if (cameraMatrices[4].w != 0.0) { + let world = vec4(transformed, 1.0); + clip = vec4( + dot(cameraMatrices[0], world), + dot(cameraMatrices[1], world), + dot(cameraMatrices[2], world), + dot(cameraMatrices[3], world), + ); } var output: VertexOutput; output.position = select(vec4(2.0, 2.0, 2.0, 1.0), clip, visible != 0u); @@ -159,7 +156,7 @@ function pbrShader(mask: number) { const normalTexture = mask & 4; return /* wgsl */ ` struct VertexOutput { - @builtin(position) position: vec4, + @invariant @builtin(position) position: vec4, @location(0) world: vec3, @location(1) normal: vec3, @location(2) uv: vec2, @@ -175,7 +172,7 @@ struct VertexOutput { @group(0) @binding(4) var scales: array>; @group(0) @binding(5) var meshInfo: array; @group(0) @binding(6) var materials: array>; -@group(0) @binding(7) var cameras: array>; +@group(0) @binding(7) var cameraMatrices: array>; ${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""} ${baseTexture ? "@group(1) @binding(1) var baseTexture: texture_2d;" : ""} ${materialTexture ? "@group(1) @binding(2) var materialTexture: texture_2d;" : ""} @@ -197,18 +194,14 @@ fn vertex( let scale = scales[instance].xyz; let world = rotate(quaternions[instance], point * scale) + positions[instance].xyz; var clip = vec4(world, 1.0); - if (cameras[2].w != 0.0) { - let cameraNode = u32(cameras[1].x); - let inverse = vec4(-quaternions[cameraNode].xyz, quaternions[cameraNode].w); - let view = rotate(inverse, world - positions[cameraNode].xyz); - if (cameras[1].y == 1.0) { - let size = max(cameras[1].z, 0.0001); - clip = vec4(view.x / (size * cameras[0].y * 0.5), view.y / (size * 0.5), -view.z / cameras[0].w, 1.0); - } 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(view.x * focal / cameras[0].y, view.y * focal, depth, -view.z); - } + if (cameraMatrices[4].w != 0.0) { + let homogeneous = vec4(world, 1.0); + clip = vec4( + dot(cameraMatrices[0], homogeneous), + dot(cameraMatrices[1], homogeneous), + dot(cameraMatrices[2], homogeneous), + dot(cameraMatrices[3], homogeneous), + ); } var output: VertexOutput; output.position = select(vec4(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 { let roughness = clamp(properties.y * ${materialTexture ? "packedMaterial.g" : "1.0"}, 0.04, 1.0); 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(tangent, bitangent, normal) * vec3(mapped.xy * extra.y, mapped.z));" : ""} - let cameraNode = u32(cameras[1].x); - let view = normalize(positions[cameraNode].xyz - input.world); + let view = normalize(cameraMatrices[4].xyz - input.world); let lightDirection = normalize(vec3(0.4, 0.7, 0.6)); let halfVector = normalize(lightDirection + view); let nDotL = max(dot(normal, lightDirection), 0.0); @@ -683,7 +675,6 @@ export class Scene { ]) addBuffer({ id, array, usage: ["storage"] }); addBuffer({ id: "accent", array: "sceneAccent", usage: ["uniform"] }); - computePipelines.push({ id: "cluster-lights", code: clusterShader, @@ -747,6 +738,7 @@ export class Scene { id: "material-linear", magFilter: "linear", minFilter: "linear", + mipmapFilter: "linear", addressModeU: "repeat", addressModeV: "repeat", }); @@ -778,7 +770,7 @@ export class Scene { ["node-scales", "nodeScales"], ["mesh-info", "meshInfo"], ["materials", "materials"], - ["cameras", "cameras"], + ["camera-matrices", "cameraMatrices"], ]) addBuffer({ id, array, usage: ["storage"] }); const forwardPipelines = new Set(); @@ -841,7 +833,9 @@ export class Scene { const material = draw.material ?? Number(this.array("meshInfo").row(mesh.id)[1]); 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 texture = (lane: number) => pointers[lane] @@ -955,7 +949,7 @@ export class Scene { "node-scales", "mesh-info", "materials", - "cameras", + "camera-matrices", ].map((resource, binding) => ({ group: 0, binding, diff --git a/addons/handles/src/camera/Camera.ts b/addons/handles/src/camera/Camera.ts index dd8beeb..07c79c5 100644 --- a/addons/handles/src/camera/Camera.ts +++ b/addons/handles/src/camera/Camera.ts @@ -1,6 +1,22 @@ import { Node, type NodeOptions } from "../Node"; import type { Scene } from "../Scene"; +function rotate(q: ArrayLike, 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) { + return left[0] * right[0] + left[1] * right[1] + left[2] * right[2]; +} + export type CameraOptions = NodeOptions & { fov?: number; near?: number; @@ -24,6 +40,7 @@ export class Camera extends Node { const nodeReady = this.ready; this.ready = nodeReady.then(async () => { this.cameraId = await scene.core.allocateObject("cameras"); + await scene.ensureRows("cameraMatrices", this.cameraId + 1, 80, "f32"); const row = scene.array("cameras").row(this.cameraId); row.set([ options.fov ?? Math.PI / 3, @@ -39,6 +56,7 @@ export class Camera extends Node { options.sensorWidth ?? 36, 1, ]); + this.refreshMatrix(); return this; }); } @@ -49,17 +67,20 @@ export class Camera extends Node { } 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]; } - set aspect(value: number) { this.cameraRow()[1] = value; } + set aspect(value: number) { this.cameraRow()[1] = value; this.refreshMatrix(); } 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]; } - 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"; } - 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]; } - set orthoSize(value: number) { this.cameraRow()[6] = value; } + set orthoSize(value: number) { this.cameraRow()[6] = value; this.refreshMatrix(); } get focalLength() { return this.cameraRow()[7]; } set focalLength(value: number) { this.cameraRow()[7] = value; } get aperture() { return this.cameraRow()[8]; } @@ -69,6 +90,18 @@ export class Camera extends Node { get sensorWidth() { return this.cameraRow()[10]; } set sensorWidth(value: number) { this.cameraRow()[10] = value; } + override get position(): Float32Array { return super.position; } + override set position(value: ArrayLike) { + super.position = value; + this.refreshMatrix(); + } + + override get quaternion(): Float32Array { return super.quaternion; } + override set quaternion(value: ArrayLike) { + super.quaternion = value; + this.refreshMatrix(); + } + lookAt(target: ArrayLike) { if (target.length !== 3) throw new RangeError("camera target"); const position = this.position; @@ -86,6 +119,47 @@ export class Camera extends Node { 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() { await this.ready; if (this.#cameraDisposed) return; diff --git a/addons/handles/src/camera/FreeCamera.ts b/addons/handles/src/camera/FreeCamera.ts index c25830f..5a551ab 100644 --- a/addons/handles/src/camera/FreeCamera.ts +++ b/addons/handles/src/camera/FreeCamera.ts @@ -91,6 +91,7 @@ export class FreeCamera extends Camera { this.position[0] += (x * Math.cos(yaw) + z * Math.sin(yaw)) * speed; this.position[1] += y * speed; this.position[2] += (x * -Math.sin(yaw) + z * Math.cos(yaw)) * speed; + this.refreshMatrix(); this.#frame = requestAnimationFrame(this.#update); }; diff --git a/addons/handles/src/materials/Texture.ts b/addons/handles/src/materials/Texture.ts index 5381ada..7a138c8 100644 --- a/addons/handles/src/materials/Texture.ts +++ b/addons/handles/src/materials/Texture.ts @@ -5,6 +5,7 @@ export type TextureOptions = { size?: [number | "canvas", number | "canvas", number?]; format?: string; usage?: string[]; + mipmaps?: boolean; transient?: boolean; }; @@ -29,6 +30,10 @@ export class Texture { typeof options.source === "string" ? await createImageBitmap(await (await fetch(options.source)).blob()) : 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({ id: resource, source: image, @@ -38,6 +43,7 @@ export class Texture { ? [image.width, image.height, 1] : [1, 1, 1]), format: options.format ?? "rgba8unorm", + mipLevelCount, usage: [ ...new Set([ ...(options.usage ?? ["copyDst"]), @@ -49,8 +55,22 @@ export class Texture { }); this.id = registration.number; await registration.ready; - if (image instanceof ImageBitmap) - await scene.core.uploadTexture(resource, image); + if (image instanceof ImageBitmap) { + 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), + ), + ); + } })(); } diff --git a/core/index.js b/core/index.js index 9d110d9..70fe8ed 100644 --- a/core/index.js +++ b/core/index.js @@ -119,11 +119,20 @@ export class YawnCore { return this.#request("switch-loadout", { id }); } - async uploadTexture(name, image) { + async uploadTexture(name, image, mipLevel = 0) { 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"); - return this.#request("upload-texture", { name, image }, [image]); + return this.#request( + "upload-texture", + { name, image, mipLevel }, + [image], + ); } async deleteTexture(name) { diff --git a/core/lib.rs b/core/lib.rs index e0ee149..0c759b7 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -174,14 +174,19 @@ impl Core { .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 = gpu .as_ref() .ok_or_else(|| JsError::new("WEBGPU_UNINITIALIZED"))?; self.store .borrow_mut() - .upload_texture(name, image, gpu) + .upload_texture(name, mip_level, image, gpu) .map_err(|error| JsError::new(&error)) } diff --git a/core/render_graph/gpu_resource.rs b/core/render_graph/gpu_resource.rs index 6d6c812..c311ce7 100644 --- a/core/render_graph/gpu_resource.rs +++ b/core/render_graph/gpu_resource.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::num::NonZeroU64; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Arc; @@ -46,7 +46,7 @@ pub struct GpuTexture { pub texture: wgpu::Texture, pub view: wgpu::TextureView, key: String, - uploaded: bool, + uploaded_mips: HashSet, } pub enum GpuPass { @@ -124,7 +124,7 @@ impl GpuResources { view: texture.create_view(&wgpu::TextureViewDescriptor::default()), texture, key, - uploaded: false, + uploaded_mips: HashSet::new(), }); physical_slots.insert(source.slot, physical); physical @@ -211,21 +211,29 @@ impl GpuResources { }; for execution in &graph.executions { let compiled = match execution { - Execution::Render(passes) => GpuPass::Render { - label: if passes.len() == 1 { - graph.passes[passes[0]].id.clone() - } else if passes - .iter() - .all(|index| graph.passes[*index].id.starts_with("forward-")) - { - format!("Forward ({} draws)", passes.len()) - } else { - format!("Render ({} draws)", passes.len()) - }, - first: passes[0], - last: *passes.last().unwrap(), - bundle: resources.render_bundle(graph, passes, gpu)?, - }, + Execution::Render(passes) => { + let (bundle, draws) = resources.render_bundle(graph, passes, gpu)?; + GpuPass::Render { + label: if passes.len() == 1 { + graph.passes[passes[0]].id.clone() + } else if passes + .iter() + .all(|index| graph.passes[*index].id.starts_with("forward-")) + { + format!("Forward ({draws} draws)") + } else if passes + .iter() + .all(|index| graph.passes[*index].id.starts_with("depth-")) + { + format!("Depth ({draws} draws)") + } else { + format!("Render ({draws} draws)") + }, + first: passes[0], + last: *passes.last().unwrap(), + bundle, + } + } Execution::Compute(index) => { let pass = &graph.passes[*index]; let pipeline = resources @@ -261,6 +269,7 @@ impl GpuResources { pub fn upload_texture( &mut self, id: &str, + mip_level: u32, image: &web_sys::ImageBitmap, gpu: &Wgpu, ) -> Result<(), String> { @@ -277,7 +286,7 @@ impl GpuResources { }, wgpu::TexelCopyTextureInfo { texture: &texture.texture, - mip_level: 0, + mip_level, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, } @@ -288,15 +297,15 @@ impl GpuResources { depth_or_array_layers: 1, }, ); - texture.uploaded = true; + texture.uploaded_mips.insert(mip_level); Ok(()) } - pub fn needs_upload(&self, id: &str) -> bool { + pub fn needs_upload(&self, id: &str, mip_level: u32) -> bool { self.texture_slots .get(id) .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> { @@ -396,7 +405,7 @@ impl GpuResources { graph: &RenderGraph, passes: &[usize], gpu: &Wgpu, - ) -> Result { + ) -> Result<(wgpu::RenderBundle, usize), String> { let pass = &graph.passes[passes[0]]; let declaration = graph .pipelines @@ -431,8 +440,10 @@ impl GpuResources { }); let mut previous_pipeline = None; let mut previous_bindings: Option<&[Binding]> = None; - for index in passes { - let pass = &graph.passes[*index]; + let mut draws = 0; + let mut at = 0; + while at < passes.len() { + let pass = &graph.passes[passes[at]]; let pipeline = self .render_pipelines .get(&pass.pipeline) @@ -468,10 +479,17 @@ impl GpuResources { buffer.slice(binding.offset..), 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( pass.draw.first_index..pass.draw.first_index + pass.draw.indices, 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 { encoder.draw( @@ -479,10 +497,15 @@ impl GpuResources { pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances, ); } + draws += 1; + at += 1; } - Ok(encoder.finish(&wgpu::RenderBundleDescriptor { - label: Some(&pass.id), - })) + Ok(( + encoder.finish(&wgpu::RenderBundleDescriptor { + label: Some(&pass.id), + }), + draws, + )) } 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 { fn label(&self) -> &str { match self { @@ -621,7 +656,7 @@ fn create_render_pipeline( primitive: primitive(&source.primitive)?, depth_stencil: depth_stencil(&source.depth_stencil)?, multisample: multisample(&source.multisample)?, - fragment: Some(wgpu::FragmentState { + fragment: (!targets.is_empty()).then_some(wgpu::FragmentState { module: &module, entry_point: Some(&source.fragment.entry), compilation_options: Default::default(), diff --git a/core/render_graph/render_graph.rs b/core/render_graph/render_graph.rs index 90a81df..8492b46 100644 --- a/core/render_graph/render_graph.rs +++ b/core/render_graph/render_graph.rs @@ -229,7 +229,7 @@ pub struct DepthAttachment { pub store: String, } -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, PartialEq, Eq)] pub struct VertexBinding { #[serde(default)] pub slot: u32, @@ -238,7 +238,7 @@ pub struct VertexBinding { pub offset: u64, } -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, PartialEq, Eq)] pub struct IndexBinding { pub resource: String, #[serde(default = "index_format")] @@ -247,7 +247,7 @@ pub struct IndexBinding { pub offset: u64, } -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Draw { #[serde(default = "three")] diff --git a/core/render_graph/store.rs b/core/render_graph/store.rs index b27f60b..50419c9 100644 --- a/core/render_graph/store.rs +++ b/core/render_graph/store.rs @@ -13,7 +13,7 @@ pub struct Loadout { #[derive(Default)] pub struct Store { graphs: HashMap, - texture_sources: HashMap, + texture_sources: HashMap>, active: Option, } @@ -32,9 +32,11 @@ impl Store { data, self.active.as_ref().map(|active| &active.resources), )?; - for (name, image) in &self.texture_sources { - if resources.needs_upload(name) { - resources.upload_texture(name, image, gpu)?; + for (name, levels) in &self.texture_sources { + for (mip_level, image) in levels { + if resources.needs_upload(name, *mip_level) { + resources.upload_texture(name, *mip_level, image, gpu)?; + } } } self.active = Some(Loadout { graph, resources }); @@ -44,23 +46,29 @@ impl Store { pub fn upload_texture( &mut self, name: String, + mip_level: u32, image: web_sys::ImageBitmap, gpu: &Wgpu, ) -> Result<(), String> { if let Some(active) = &mut self.active { 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(); } Ok(()) } pub fn delete_texture(&mut self, name: &str) { - if let Some(image) = self.texture_sources.remove(name) { - image.close(); + if let Some(levels) = self.texture_sources.remove(name) { + for image in levels.into_values() { + image.close(); + } } } @@ -102,9 +110,11 @@ impl Store { data, self.active.as_ref().map(|active| &active.resources), )?; - for (name, image) in &self.texture_sources { - if resources.needs_upload(name) { - resources.upload_texture(name, image, gpu)?; + for (name, levels) in &self.texture_sources { + for (mip_level, image) in levels { + if resources.needs_upload(name, *mip_level) { + resources.upload_texture(name, *mip_level, image, gpu)?; + } } } self.active = Some(Loadout { graph, resources }); diff --git a/core/renderer/render.rs b/core/renderer/render.rs index e841ee9..72c5013 100644 --- a/core/renderer/render.rs +++ b/core/renderer/render.rs @@ -110,6 +110,7 @@ impl RenderLoop { while !submission.completed.load(Ordering::Acquire) { TimeoutFuture::new(0).await; } + let wall_milliseconds = js_sys::Date::now() - started; if let Some(profile) = submission.profile { while profile.state() == 0 { TimeoutFuture::new(0).await; @@ -129,10 +130,15 @@ impl RenderLoop { .iter() .filter_map(|pass| pass["milliseconds"].as_f64()) .sum::(); + let gpu = gpu.borrow(); + let gpu = gpu.as_ref().unwrap(); *control.profile.borrow_mut() = Some( serde_json::json!({ "frame": frame, "milliseconds": milliseconds, + "wallMilliseconds": wall_milliseconds, + "adapter": gpu.adapter, + "canvas": { "width": gpu.width, "height": gpu.height }, "passes": passes, }) .to_string(), diff --git a/core/renderer/wgpu.rs b/core/renderer/wgpu.rs index ce742f3..e90594d 100644 --- a/core/renderer/wgpu.rs +++ b/core/renderer/wgpu.rs @@ -8,6 +8,7 @@ pub struct Wgpu { pub width: u32, pub height: u32, pub timestamp_queries: bool, + pub adapter: String, } impl Wgpu { @@ -27,11 +28,21 @@ impl Wgpu { let adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { compatible_surface: Some(&surface), - ..Default::default() + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, }) .await .map_err(|_| "WEBGPU_UNAVAILABLE")?; 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 .request_device(&wgpu::DeviceDescriptor { required_features, @@ -54,6 +65,7 @@ impl Wgpu { width, height, timestamp_queries: required_features.contains(wgpu::Features::TIMESTAMP_QUERY), + adapter: adapter_name, }) } } diff --git a/core/worker.js b/core/worker.js index eb1c420..61d933c 100644 --- a/core/worker.js +++ b/core/worker.js @@ -41,7 +41,7 @@ addEventListener("message", async ({ data: message }) => { core.switch_loadout(message.id); break; case "upload-texture": - core.upload_texture(message.name, message.image); + core.upload_texture(message.name, message.mipLevel, message.image); break; case "delete-texture": core.delete_texture(message.name); diff --git a/docs/.vitepress/Playground.vue b/docs/.vitepress/Playground.vue index 9f3c01f..6fb9a20 100644 --- a/docs/.vitepress/Playground.vue +++ b/docs/.vitepress/Playground.vue @@ -28,6 +28,7 @@ const fps = ref(0); const profilerOpen = ref(false); const profilerSupported = ref(null); const profile = ref(null); +const adapterInfo = ref(""); let generation = 0; let current; let stopProfile; @@ -88,6 +89,20 @@ async function attachProfiler(runId = generation) { profile.value = null; profilerSupported.value = null; 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; if (!core?.onProfile || !core?.setProfiler) { profilerSupported.value = false; @@ -273,6 +288,13 @@ onUnmounted(() => { GPU passes {{ profile.milliseconds.toFixed(2) }} ms +
+ {{ adapterInfo || profile.adapter }} + + {{ profile.canvas.width }}×{{ profile.canvas.height }} · + {{ profile.wallMilliseconds.toFixed(2) }} ms wall + +

Timestamp queries are unavailable on this GPU.

@@ -522,6 +544,13 @@ canvas { color: #60a5fa; font-weight: 700; } +.profiler-meta { + display: grid; + gap: 3px; + margin: -5px 0 12px; + color: #64748b; + font-size: 10px; +} .profiler p { color: #94a3b8; } diff --git a/docs/.vitepress/playgrounds.js b/docs/.vitepress/playgrounds.js index 1764c8f..6fcd92d 100644 --- a/docs/.vitepress/playgrounds.js +++ b/docs/.vitepress/playgrounds.js @@ -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 encode = (value) => { @@ -403,5 +496,6 @@ export const playgrounds = { compute: { title: "Compute pass", code: compute }, post: { title: "HDR post processing", code: post }, importing: { title: "glTF import and BVH picking", code: importing }, + benchmark: { title: "Forward benchmark", code: benchmark }, core: { title: "Direct core graph", code: core }, }; diff --git a/docs/guide/cameras.md b/docs/guide/cameras.md index 3a7d567..e431315 100644 --- a/docs/guide/cameras.md +++ b/docs/guide/cameras.md @@ -1,6 +1,6 @@ # 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 @@ -72,7 +72,7 @@ follow.stop(); 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. diff --git a/docs/guide/core.md b/docs/guide/core.md index 35687ee..80a1dd2 100644 --- a/docs/guide/core.md +++ b/docs/guide/core.md @@ -56,7 +56,9 @@ await core.setProfiler(false); 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 playground’s **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. diff --git a/docs/guide/materials.md b/docs/guide/materials.md index a1e2e90..fdbe905 100644 --- a/docs/guide/materials.md +++ b/docs/guide/materials.md @@ -30,7 +30,7 @@ await albedo.ready; 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