diff --git a/Cargo.lock b/Cargo.lock index 98d3a49..02687e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -717,6 +726,7 @@ dependencies = [ name = "yawn-core" version = "0.1.0" dependencies = [ + "futures-channel", "js-sys", "serde", "serde_json", diff --git a/addons/handles/src/Scene.ts b/addons/handles/src/Scene.ts index 497cd89..29f1b41 100644 --- a/addons/handles/src/Scene.ts +++ b/addons/handles/src/Scene.ts @@ -230,6 +230,9 @@ function pbrShader( transmission: boolean, textureInfo: ArrayLike, extensionInfo: ArrayLike, + sharedOcclusion: boolean, + lightMask: number, + directToCanvas: boolean, ) { const names = [ "baseTexture", @@ -306,11 +309,11 @@ struct VertexOutput { @group(0) @binding(5) var meshInfo: array; @group(0) @binding(6) var materials: array>; @group(0) @binding(7) var cameraMatrices: array>; -@group(0) @binding(8) var pointLights: array>; -@group(0) @binding(9) var spotLights: array>; -@group(0) @binding(10) var directionalLights: array>; +${lightMask & 1 ? "@group(0) @binding(8) var pointLights: array>;" : ""} +${lightMask & 2 ? "@group(0) @binding(9) var spotLights: array>;" : ""} +${lightMask & 4 ? "@group(0) @binding(10) var directionalLights: array>;" : ""} ${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""} -${names.map((name, slot) => (texture(slot) ? `@group(1) @binding(${slot + 1}) var ${name}: texture_2d;` : "")).join("\n")} +${names.map((name, slot) => (texture(slot) && !(slot === 4 && sharedOcclusion) ? `@group(1) @binding(${slot + 1}) var ${name}: texture_2d;` : "")).join("\n")} ${transmission ? "@group(2) @binding(0) var sceneColor: texture_2d;\n@group(2) @binding(1) var sceneSampler: sampler;" : ""} fn rotate(rotor: vec4, value: vec3) -> vec3 { @@ -357,11 +360,10 @@ fn pow5(value: f32) -> f32 { return squared * squared * value; } -fn directContribution(normal: vec3, view: vec3, lightDirection: vec3, radiance: vec3, baseColor: vec3, metallic: f32, roughness: f32, f0: vec3) -> vec3 { +fn directContribution(normal: vec3, view: vec3, lightDirection: vec3, radiance: vec3, diffuseColor: vec3, nDotV: f32, roughness: f32, f0: vec3) -> vec3 { let nDotL = max(dot(normal, lightDirection), 0.0); if (nDotL <= 0.0) { return vec3(0.0); } let halfVector = normalize(lightDirection + view); - let nDotV = max(dot(normal, view), 0.0001); let nDotH = max(dot(normal, halfVector), 0.0); let vDotH = max(dot(view, halfVector), 0.0); let fresnel = f0 + (vec3(1.0) - f0) * pow5(1.0 - vDotH); @@ -372,11 +374,14 @@ fn directContribution(normal: vec3, view: vec3, lightDirection: vec3, view: vec3, world: vec3, baseColor: vec3, metallic: f32, roughness: f32, f0: vec3) -> vec3 { +${ + lightMask & 1 + ? ` +fn pointContribution(index: u32, normal: vec3, view: vec3, world: vec3, diffuseColor: vec3, nDotV: f32, roughness: f32, f0: vec3) -> vec3 { let colorIntensity = pointLights[index * 2u]; let state = pointLights[index * 2u + 1u]; if (state.y == 0.0 || state.x <= 0.0) { return vec3(0.0); } @@ -385,10 +390,16 @@ fn pointContribution(index: u32, normal: vec3, view: vec3, world: vec3 let falloff = max(1.0 - distance / state.x, 0.0); let radiance = colorIntensity.rgb * colorIntensity.a * falloff * falloff * 0.08; let sourceRoughness = clamp(roughness + max(state.z, 0.0) / max(distance, 0.0001), roughness, 1.0); - return directContribution(normal, view, offset / max(distance, 0.0001), radiance, baseColor, metallic, sourceRoughness, f0); + return directContribution(normal, view, offset / max(distance, 0.0001), radiance, diffuseColor, nDotV, sourceRoughness, f0); +} +` + : "" } -fn spotContribution(index: u32, normal: vec3, view: vec3, world: vec3, baseColor: vec3, metallic: f32, roughness: f32, f0: vec3) -> vec3 { +${ + lightMask & 2 + ? ` +fn spotContribution(index: u32, normal: vec3, view: vec3, world: vec3, diffuseColor: vec3, nDotV: f32, roughness: f32, f0: vec3) -> vec3 { let colorIntensity = spotLights[index * 2u]; let state = spotLights[index * 2u + 1u]; if (state.w == 0.0 || state.x <= 0.0) { return vec3(0.0); } @@ -398,16 +409,27 @@ fn spotContribution(index: u32, normal: vec3, view: vec3, world: vec3< let cone = smoothstep(cos(state.z), cos(state.y), dot(direction, fromLight / max(distance, 0.0001))); let falloff = max(1.0 - distance / state.x, 0.0); let radiance = colorIntensity.rgb * colorIntensity.a * cone * falloff * falloff * 0.08; - return directContribution(normal, view, -fromLight / max(distance, 0.0001), radiance, baseColor, metallic, roughness, f0); + return directContribution(normal, view, -fromLight / max(distance, 0.0001), radiance, diffuseColor, nDotV, roughness, f0); +} +` + : "" } -fn directionalContribution(index: u32, normal: vec3, view: vec3, baseColor: vec3, metallic: f32, roughness: f32, f0: vec3) -> vec3 { +${ + lightMask & 4 + ? ` +fn directionalContribution(index: u32, normal: vec3, view: vec3, diffuseColor: vec3, nDotV: f32, roughness: f32, f0: vec3) -> vec3 { let colorIntensity = directionalLights[index * 2u]; let state = directionalLights[index * 2u + 1u]; if (state.x == 0.0) { return vec3(0.0); } let lightDirection = normalize(rotate(rotors[index], vec3(0.0, 0.0, 1.0))); - return directContribution(normal, view, lightDirection, colorIntensity.rgb * colorIntensity.a * 0.1, baseColor, metallic, roughness, f0); + return directContribution(normal, view, lightDirection, colorIntensity.rgb * colorIntensity.a * 0.1, diffuseColor, nDotV, roughness, f0); } +` + : "" +} + +${directToCanvas ? canvasOutputShader : ""} @fragment fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @location(0) vec4 { @@ -432,6 +454,7 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l ${normalTexture ? `let tangent = normalize(input.tangent.xyz - normal * dot(input.tangent.xyz, normal)); let bitangent = cross(normal, tangent) * input.tangent.w; let mapped = ${sample(2)}.xyz * 2.0 - 1.0; normal = normalize(mat3x3(tangent, bitangent, normal) * vec3(mapped.xy * extra.y, mapped.z));` : ""} let view = normalize(cameraMatrices[4].xyz - input.world); let nDotV = max(dot(normal, view), 0.0001); + let diffuseColor = base.rgb * (1.0 - metallic) / 3.14159265; let specularWeight = extension2.z * ${sample(11)}.a; let dielectricColor = vec3(${number(dielectric)} * specularWeight) * extension6.rgb * ${sample(12)}.rgb; var f0 = ${specularGlossiness ? "packedMaterial.rgb * extension3.rgb" : "mix(dielectricColor, base.rgb, metallic)"}; @@ -448,30 +471,48 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l let fresnelPower = pow5(1.0 - nDotV); let ambient = vec3(bitcast(clusters[${compactAmbientStart}]), bitcast(clusters[${compactAmbientStart + 1}]), bitcast(clusters[${compactAmbientStart + 2}])); var directLight = vec3(0.0); + ${ + lightMask & 1 + ? ` let pointCount = clusters[10]; if (pointCount <= ${compactLightLimit}u) { for (var light = 0u; light < pointCount; light++) { - directLight += pointContribution(clusters[${compactPointStart}u + light], normal, view, input.world, base.rgb, metallic, roughness, f0); + directLight += pointContribution(clusters[${compactPointStart}u + light], normal, view, input.world, diffuseColor, nDotV, roughness, f0); } } else { for (var light = 0u; light < arrayLength(&pointLights) / 2u; light++) { - directLight += pointContribution(light, normal, view, input.world, base.rgb, metallic, roughness, f0); + directLight += pointContribution(light, normal, view, input.world, diffuseColor, nDotV, roughness, f0); } } + ` + : "" + } + ${ + lightMask & 2 + ? ` let spotCount = clusters[${compactSpotCount}]; if (spotCount <= ${compactLightLimit}u) { for (var light = 0u; light < spotCount; light++) { - directLight += spotContribution(clusters[${compactSpotStart}u + light], normal, view, input.world, base.rgb, metallic, roughness, f0); + directLight += spotContribution(clusters[${compactSpotStart}u + light], normal, view, input.world, diffuseColor, nDotV, roughness, f0); } } else { for (var light = 0u; light < arrayLength(&spotLights) / 2u; light++) { - directLight += spotContribution(light, normal, view, input.world, base.rgb, metallic, roughness, f0); + directLight += spotContribution(light, normal, view, input.world, diffuseColor, nDotV, roughness, f0); } } - for (var light = 0u; light < arrayLength(&directionalLights) / 2u; light++) { - directLight += directionalContribution(light, normal, view, base.rgb, metallic, roughness, f0); + ` + : "" } - let occlusion = mix(1.0, ${sample(4)}.r, extension2.w); + ${ + lightMask & 4 + ? ` + for (var light = 0u; light < arrayLength(&directionalLights) / 2u; light++) { + directLight += directionalContribution(light, normal, view, diffuseColor, nDotV, roughness, f0); + } + ` + : "" + } + let occlusion = mix(1.0, ${sharedOcclusion ? "packedMaterial" : sample(4)}.r, extension2.w); let emissive = vec3(properties.z, properties.w, extra.x) * ${sample(3, "vec4(1.0)")}.rgb * extension1.w; ${sheen ? "let sheen = extension3.rgb * pow(1.0 - nDotV, mix(2.0, 8.0, extension3.w));" : ""} ${ @@ -508,7 +549,7 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l color = mix(color, transmitted, transmissionAmount);` : "" } - return vec4(color * accent.a, ${alphaMode === 2 ? "base.a" : "1.0"}); + return ${directToCanvas ? "canvasOutput(vec4(color * accent.a, 1.0))" : `vec4(color * accent.a, ${alphaMode === 2 ? "base.a" : "1.0"})`}; }`; } @@ -559,13 +600,23 @@ function effectFragment(kind: string, options: Record) { }`; } +function toneMapping(toneMap: string) { + return toneMap === "reinhard" + ? "color / (color + vec3(1.0))" + : toneMap === "linear" + ? "clamp(color, vec3(0.0), vec3(1.0))" + : "clamp((color * (2.51 * color + vec3(0.03))) / (color * (2.43 * color + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0))"; +} + +// Match the intermediate rgba16float rounding before applying the same presentation transform. +const canvasOutputShader = /* wgsl */ ` +fn canvasOutput(value: vec4) -> vec4 { + let rounded = vec4(unpack2x16float(pack2x16float(value.rg)), unpack2x16float(pack2x16float(value.ba))); + let color = max(rounded.rgb, vec3(0.0)); + return vec4(${toneMapping("aces")}, rounded.a); +}`; + function presentShader(toneMap: string, composite: "scene" | undefined) { - const tone = - toneMap === "reinhard" - ? "color / (color + vec3(1.0))" - : toneMap === "linear" - ? "clamp(color, vec3(0.0), vec3(1.0))" - : "clamp((color * (2.51 * color + vec3(0.03))) / (color * (2.43 * color + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0))"; return `${fullscreenVertex} @group(0) @binding(0) var source: texture_2d; @group(0) @binding(1) var sourceSampler: sampler; @@ -573,7 +624,7 @@ ${composite === "scene" ? "@group(0) @binding(2) var scene: texture_2d;" : @fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { let value = textureSample(source, sourceSampler, input.uv); let color = max(${composite === "scene" ? "value.rgb + textureSample(scene, sourceSampler, input.uv).rgb * (1.0 - value.a)" : "value.rgb"}, vec3(0.0)); - return vec4(${tone}, ${composite ? "1.0" : "value.a"}); + return vec4(${toneMapping(toneMap)}, ${composite ? "1.0" : "value.a"}); }`; } @@ -611,6 +662,8 @@ export class Scene { readonly core: YawnCore; readonly ready: Promise; readonly hdr: boolean; + /** Fuse tone mapping into opaque PBR draws when no linear-color consumers need HDR. */ + readonly directToCanvas: boolean; readonly clearColor: readonly [number, number, number, number]; #graphUpdates = Promise.resolve(); #renderGraph?: object; @@ -635,6 +688,11 @@ export class Scene { #signals?: Float32Array; #arrays = new WeakMap(); #views = new WeakMap(); + #lights = { + point: new Set(), + spot: new Set(), + directional: new Set(), + }; constructor( canvas: HTMLCanvasElement, @@ -643,6 +701,7 @@ export class Scene { debug?: boolean; fps?: number; hdr?: boolean; + directToCanvas?: boolean; clearColor?: ArrayLike; } = {}, ) { @@ -653,6 +712,7 @@ export class Scene { ) throw new TypeError("Scene clearColor must contain four finite numbers"); this.hdr = options.hdr ?? true; + this.directToCanvas = options.directToCanvas ?? false; this.clearColor = clearColor as [number, number, number, number]; this.core = new YawnCore(canvas, { arenaBytes: options.arenaBytes, @@ -1171,6 +1231,21 @@ export class Scene { return this.updateRenderGraph(); } + /** Light handles register their lifetime, not intensity; hot edits never select a new variant. */ + setLight( + kind: "point" | "spot" | "directional", + id: number, + present: boolean, + ) { + const lights = this.#lights[kind]; + const hadType = lights.size > 0; + if (present) lights.add(id); + else lights.delete(id); + return hadType !== Boolean(lights.size) + ? this.updateRenderGraph() + : Promise.resolve(); + } + updateRenderGraph() { this.markDirty(true); if (this.#graphBatchDepth) { @@ -1327,16 +1402,38 @@ export class Scene { const hasTransmission = forwardDraws.some( (draw) => this.array("materialExtensions").row(draw.material)[0] > 0, ); + // Custom compute can author light rows directly, so retain the generic shader in that case. + const lightMask = this.#computePasses.size + ? 7 + : (this.#lights.point.size ? 1 : 0) | + (this.#lights.spot.size ? 2 : 0) | + (this.#lights.directional.size ? 4 : 0); + const directToCanvas = + this.directToCanvas && + this.hdr && + !this.#computePasses.size && + !this.#effects.size && + !this.#shaders.size && + !hasTransmission && + forwardDraws.length > 0 && + forwardDraws.every( + (draw) => + (Math.round(this.array("materials").row(draw.material)[11]) & 3) !== + 2 && + draw.geometry.data.has("normals") && + draw.geometry.data.has("uvs"), + ); const compositeSceneColor = hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0; const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm"; - addTexture({ - id: "hdr", - format: hdrFormat, - size: ["canvas", "canvas", 1], - usage: ["render", "sampled"], - transient: false, - }); + if (!directToCanvas) + addTexture({ + id: "hdr", + format: hdrFormat, + size: ["canvas", "canvas", 1], + usage: ["render", "sampled"], + transient: false, + }); addTexture({ id: "depth", format: "depth24plus", @@ -1374,6 +1471,31 @@ export class Scene { let previous = computeIds.length ? computeIds : ["cluster-lights"]; const bucketRows = new Map(); + if (directToCanvas) { + // Shares attachments with the forward draws, so core merges this constant background + // into their physical render pass. Use the same HDR rounding/tone mapping for the clear. + renderPipelines.push({ + id: "canvas-background", + code: `${fullscreenVertex}\n${canvasOutputShader}\n@fragment fn fragment() -> @location(0) vec4 { return canvasOutput(vec4(${this.clearColor.map((value) => (Number.isInteger(value) ? `${value}.0` : String(value))).join(", ")})); }`, + vertex: { entry: "vertex" }, + fragment: { entry: "fragment", targets: [{ format: "canvas" }] }, + depthStencil: { + format: "depth24plus", + depth_write_enabled: false, + depth_compare: "always", + }, + }); + passes.push({ + id: "canvas-background", + type: "render", + pipeline: "canvas-background", + after: previous, + color: [{ resource: "canvas", clear: [0, 0, 0, 0] }], + depth: { resource: "depth", clear: 1 }, + draw: { vertices: 3 }, + }); + previous = ["canvas-background"]; + } if (!forwardDraws.length) { renderPipelines.push({ id: "empty-forward", @@ -1435,10 +1557,14 @@ export class Scene { sync: "manual", }); const forwardPipelines = new Map(); - let firstColor = true; - let firstDepth = true; + let firstColor = !directToCanvas; + let firstDepth = !directToCanvas; let sceneColorReady = false; - let forwardTarget = compositeSceneColor ? "scene-color" : "hdr"; + let forwardTarget = directToCanvas + ? "canvas" + : compositeSceneColor + ? "scene-color" + : "hdr"; const attachments = () => { const color = firstColor ? { clear: [...this.clearColor] } @@ -1518,6 +1644,13 @@ export class Scene { if (extensionInfo[11] <= 0) mask &= ~(1 << 4); if ((flags & 8) !== 0 && !transmitted) mask &= (1 << 0) | (1 << 3); const textureInfo = this.array("materialTextureInfo").row(material); + // All material samples currently use one sampler. Reuse only identical texture/UV reads. + const sharedOcclusion = + Boolean(mask & (1 << 1) && mask & (1 << 4)) && + materialTextures[1] === materialTextures[4] && + Array.from(textureInfo) + .slice(8, 15) + .every((value, lane) => value === textureInfo[32 + lane]); let clearCompositeLayer = false; if (transmitted || (flags & 3) === 2) flushBuckets(); if (transmitted && !sceneColorReady) { @@ -1558,7 +1691,16 @@ export class Scene { } } const code = detailed - ? pbrShader(mask, flags, transmitted, textureInfo, extensionInfo) + ? pbrShader( + mask, + flags, + transmitted, + textureInfo, + extensionInfo, + sharedOcclusion, + lightMask, + directToCanvas, + ) : basicForwardShader; const pipelineKey = `${flags}-${transmitted ? 1 : 0}\n${code}`; let pipeline = forwardPipelines.get(pipelineKey); @@ -1583,7 +1725,7 @@ export class Scene { entry: "fragment", targets: [ { - format: hdrFormat, + format: directToCanvas ? "canvas" : hdrFormat, ...((flags & 3) === 2 ? { blend: { @@ -1644,7 +1786,7 @@ export class Scene { binding: 10, resource: "directional-lights", }, - ] + ].filter((_, index) => lightMask & (1 << index)) : []), ...(detailed && mask ? [ @@ -1656,7 +1798,9 @@ export class Scene { : "material-linear", }, ...materialTextures.flatMap((value, lane) => - value && (mask & (1 << lane)) !== 0 + value && + (mask & (1 << lane)) !== 0 && + !(lane === 4 && sharedOcclusion) ? [ { group: 1, @@ -1770,32 +1914,34 @@ export class Scene { previous = [pipeline]; } - const toneMap = String( - [...this.#effects.values()].find( - (effect) => effect.kind === "colorGrading", - )?.options.toneMap ?? "aces", - ); - renderPipelines.push({ - id: "present", - code: presentShader(toneMap, compositeSceneColor ? "scene" : undefined), - vertex: { entry: "vertex" }, - fragment: { entry: "fragment", targets: [{ format: "canvas" }] }, - }); - passes.push({ - id: "present", - type: "render", - pipeline: "present", - after: previous, - bindings: [ - { group: 0, binding: 0, resource: input }, - { group: 0, binding: 1, resource: "linear" }, - ...(compositeSceneColor - ? [{ group: 0, binding: 2, resource: "scene-color" }] - : []), - ], - color: [{ resource: "canvas", clear: [...this.clearColor] }], - draw: { vertices: 3 }, - }); + if (!directToCanvas) { + const toneMap = String( + [...this.#effects.values()].find( + (effect) => effect.kind === "colorGrading", + )?.options.toneMap ?? "aces", + ); + renderPipelines.push({ + id: "present", + code: presentShader(toneMap, compositeSceneColor ? "scene" : undefined), + vertex: { entry: "vertex" }, + fragment: { entry: "fragment", targets: [{ format: "canvas" }] }, + }); + passes.push({ + id: "present", + type: "render", + pipeline: "present", + after: previous, + bindings: [ + { group: 0, binding: 0, resource: input }, + { group: 0, binding: 1, resource: "linear" }, + ...(compositeSceneColor + ? [{ group: 0, binding: 2, resource: "scene-color" }] + : []), + ], + color: [{ resource: "canvas", clear: [...this.clearColor] }], + draw: { vertices: 3 }, + }); + } const graph = { id: "scene", diff --git a/addons/handles/src/camera/ArcRotateCamera.ts b/addons/handles/src/camera/ArcRotateCamera.ts index 609b144..19392f6 100644 --- a/addons/handles/src/camera/ArcRotateCamera.ts +++ b/addons/handles/src/camera/ArcRotateCamera.ts @@ -25,6 +25,9 @@ export class ArcRotateCamera extends Camera { #controls?: ArcRotateControls; #pointer?: { x: number; y: number; button: number }; #frame = 0; + #inertiaFrame = 0; + #lastFrame = 0; + #motion = [0, 0, 0, 0, 0]; // orbit x/y, pan x/y, logarithmic zoom constructor(scene: Scene, options: ArcRotateCameraOptions = {}) { super(scene, options); @@ -116,6 +119,9 @@ export class ArcRotateCamera extends Camera { } if (this.#frame) cancelAnimationFrame(this.#frame); this.#frame = 0; + if (this.#inertiaFrame) cancelAnimationFrame(this.#inertiaFrame); + this.#inertiaFrame = 0; + this.#motion.fill(0); this.#pointer = undefined; this.#controls = undefined; } @@ -161,7 +167,14 @@ export class ArcRotateCamera extends Camera { const y = event.clientY - this.#pointer.y; this.#pointer.x = event.clientX; this.#pointer.y = event.clientY; - if (this.#pointer.button === 2) { + const offset = this.#pointer.button === 2 ? 2 : 0; + this.#motion[offset] += x; + this.#motion[offset + 1] += y; + this.#scheduleInertia(); + }; + + #applyDrag(x: number, y: number, pan: boolean) { + if (pan) { const row = this.cameraRow(); this.#updateTransform(() => { const element = this.#controls!.element; @@ -184,26 +197,46 @@ export class ArcRotateCamera extends Camera { row[18] -= horizontal * sinAlpha + vertical * cosBeta * cosAlpha; }); } else { - const speed = this.#controls.orbitSpeed ?? 0.005; + const speed = this.#controls?.orbitSpeed ?? 0.005; const row = this.cameraRow(); this.#updateTransform(() => { row[13] -= x * speed; row[14] = Math.min( Math.PI - 0.001, - Math.max(0.001, row[14] + y * speed), + Math.max(0.001, row[14] - y * speed), ); }); } - }; + } #wheel = (event: WheelEvent) => { event.preventDefault(); - const row = this.cameraRow(); - this.#updateTransform(() => { - row[15] = Math.max( - 0.01, - row[15] * Math.exp(event.deltaY * (this.#controls?.zoomSpeed ?? 0.001)), - ); + this.#motion[4] += event.deltaY * (this.#controls?.zoomSpeed ?? 0.001); + this.#scheduleInertia(); + }; + + #scheduleInertia() { + if (this.#inertiaFrame) return; + this.#lastFrame = performance.now(); + this.#inertiaFrame = requestAnimationFrame(this.#tickInertia); + } + + #tickInertia = (now: number) => { + this.#inertiaFrame = 0; + const fraction = -Math.expm1(-Math.max(0, now - this.#lastFrame) / 80); + this.#lastFrame = now; + const step = this.#motion.map((remaining, index) => { + const value = + Math.abs(remaining) < 0.00001 ? remaining : remaining * fraction; + this.#motion[index] -= value; + return value; }); + this.batchTransformChanges(() => { + if (step[0] || step[1]) this.#applyDrag(step[0], step[1], false); + if (step[2] || step[3]) this.#applyDrag(step[2], step[3], true); + if (step[4]) this.radius *= Math.exp(step[4]); + }); + if (this.#motion.some(Boolean)) + this.#inertiaFrame = requestAnimationFrame(this.#tickInertia); }; #pollController = () => { const pad = navigator.getGamepads?.().find(Boolean); diff --git a/addons/handles/src/lights/DirectionalLight.ts b/addons/handles/src/lights/DirectionalLight.ts index bbbe9ef..99f63fd 100644 --- a/addons/handles/src/lights/DirectionalLight.ts +++ b/addons/handles/src/lights/DirectionalLight.ts @@ -10,6 +10,7 @@ export class DirectionalLight extends Node { this.ready = nodeReady.then(async () => { const array = await scene.ensureRows("directionalLights", this.id + 1, 32, "f32"); array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, 1, 0, 0, 0]); + await scene.setLight("directional", this.id, true); return this; }); } @@ -21,6 +22,7 @@ export class DirectionalLight extends Node { await this.ready; if (this.disposed) return; this.scene.array("directionalLights").row(this.id).fill(0); + await this.scene.setLight("directional", this.id, false); await super.dispose(); } } diff --git a/addons/handles/src/lights/PointLight.ts b/addons/handles/src/lights/PointLight.ts index 887d05c..10d36e8 100644 --- a/addons/handles/src/lights/PointLight.ts +++ b/addons/handles/src/lights/PointLight.ts @@ -29,6 +29,7 @@ export class PointLight extends Node { options.radius ?? 0, 0, ]); + await scene.setLight("point", this.id, true); return this; }); } @@ -62,6 +63,7 @@ export class PointLight extends Node { await this.ready; if (this.disposed) return; this.scene.array("pointLights").row(this.id).fill(0); + await this.scene.setLight("point", this.id, false); await super.dispose(); } } diff --git a/addons/handles/src/lights/SpotLight.ts b/addons/handles/src/lights/SpotLight.ts index b3dfcba..25e968e 100644 --- a/addons/handles/src/lights/SpotLight.ts +++ b/addons/handles/src/lights/SpotLight.ts @@ -19,6 +19,7 @@ export class SpotLight extends Node { ...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, options.range ?? 10, options.innerAngle ?? 0.35, options.outerAngle ?? 0.7, 1, ]); + await scene.setLight("spot", this.id, true); return this; }); } @@ -32,6 +33,7 @@ export class SpotLight extends Node { await this.ready; if (this.disposed) return; this.scene.array("spotLights").row(this.id).fill(0); + await this.scene.setLight("spot", this.id, false); await super.dispose(); } } diff --git a/addons/handles/test/handles.test.ts b/addons/handles/test/handles.test.ts index 51c4468..ced6820 100644 --- a/addons/handles/test/handles.test.ts +++ b/addons/handles/test/handles.test.ts @@ -292,7 +292,31 @@ test("Node, ArcRotateCamera, and light handles update their shared rows", async assert.equal(scene.array("pointLights").row(light.id)[6], 3); }); -test("ArcRotateCamera pans one projected world pixel per pointer pixel", async () => { +test("ArcRotateCamera damps pan, orbit and zoom with reversed vertical orbit", async (t) => { + let now = 0; + let next = 1; + const frames = new Map(); + const originalRequest = globalThis.requestAnimationFrame; + const originalCancel = globalThis.cancelAnimationFrame; + t.mock.method(performance, "now", () => now); + globalThis.requestAnimationFrame = (callback) => { + const id = next++; + frames.set(id, callback); + return id; + }; + globalThis.cancelAnimationFrame = (id) => { + frames.delete(id); + }; + t.after(() => { + globalThis.requestAnimationFrame = originalRequest; + globalThis.cancelAnimationFrame = originalCancel; + }); + const advance = (ms: number) => { + now += ms; + const callbacks = [...frames.values()]; + frames.clear(); + for (const callback of callbacks) callback(now); + }; const listeners = new Map void>>(); const element = { clientWidth: 400, @@ -329,7 +353,43 @@ test("ArcRotateCamera pans one projected world pixel per pointer pixel", async ( pointerId: 1, }); dispatch("pointermove", { clientX: 120, clientY: 110 }); - + dispatch("pointerup", {}); + advance(80); + close(camera.position, [-2 * (1 - Math.exp(-1)), 1 - Math.exp(-1), 10]); + advance(1600); close(camera.position, [-2, 1, 10]); + advance(16); + assert.equal(frames.size, 0); + const orbit = () => { + dispatch("pointerdown", { + button: 0, + clientX: 100, + clientY: 100, + pointerId: 1, + }); + dispatch("pointermove", { clientX: 140, clientY: 120 }); + dispatch("pointerup", {}); + }; + orbit(); + advance(40); + const released = camera.alpha; + advance(40); + assert.ok(camera.alpha < released); + const pose = [camera.alpha, camera.beta]; + close(pose, [ + -0.2 * (1 - Math.exp(-1)), + Math.PI / 2 - 0.1 * (1 - Math.exp(-1)), + ]); camera.detachControls(); + camera.alpha = 0; + camera.beta = Math.PI / 2; + camera.attachControls({ element }); + orbit(); + advance(80); + close([camera.alpha, camera.beta], pose); + dispatch("wheel", { deltaY: 100 }); + advance(80); + close([camera.radius], [10 * Math.exp(0.1 * (1 - Math.exp(-1)))]); + camera.detachControls(); + assert.equal(frames.size, 0); }); diff --git a/addons/handles/test/scene-graph.test.ts b/addons/handles/test/scene-graph.test.ts index 0575192..c226d15 100644 --- a/addons/handles/test/scene-graph.test.ts +++ b/addons/handles/test/scene-graph.test.ts @@ -4,6 +4,11 @@ import { Mesh } from "../src/Mesh"; import { FXAA } from "../src/PostProcesses/FXAA"; import { PBRMaterial } from "../src/materials/PBRMaterial"; import { Texture } from "../src/materials/Texture"; +import { ShaderMaterial } from "../src/materials/ShaderMaterial"; +import { PointLight } from "../src/lights/PointLight"; +import { SpotLight } from "../src/lights/SpotLight"; +import { DirectionalLight } from "../src/lights/DirectionalLight"; +import { ComputePass } from "../src/ComputePass"; import { bucketDraws, createScene, @@ -34,6 +39,11 @@ test("empty scenes use neutral authored defaults without placeholder geometry", test("opaque PBR graph has no implicit environment and compacts sparse lights", async () => { const scene = await createScene(); + await Promise.all([ + new PointLight(scene).ready, + new SpotLight(scene).ready, + new DirectionalLight(scene).ready, + ]); const material = new PBRMaterial(scene); await material.ready; const mesh = detailedMesh(scene, material); @@ -451,3 +461,151 @@ test("serialized render graphs compile into explicitly selectable loadouts", asy assert.equal(loadoutId, `loadout-${before + 1}`); assert.equal(core.loadouts.at(-1), loadoutId); }); + +test("packed ORM reads are shared only for the same texture and UV transform", async () => { + const scene = await createScene(); + const orm = new Texture(scene, { size: [1, 1, 1] }); + const other = new Texture(scene, { size: [1, 1, 1] }); + await Promise.all([orm.ready, other.ready]); + for (const [occlusion, info, shared] of [ + [orm, {}, true], + [other, {}, false], + [orm, { texCoord: 1 }, false], + [orm, { offset: [0.25, -0.1] }, false], + [orm, { scale: [2, 1] }, false], + [orm, { rotation: 0.4 }, false], + ] as const) { + const material = new PBRMaterial(scene, { + metallicRoughnessTexture: orm, + occlusionTexture: occlusion, + textureInfos: [{}, {}, {}, {}, info], + }); + await material.ready; + const mesh = await detailedMesh(scene, material).ready; + const graph = latestGraph(scene); + const pass = forwardPasses(graph).at(-1); + const code = renderPipeline(graph, pass.pipeline).code; + assert.equal(code.includes("mix(1.0, packedMaterial.r"), shared); + assert.equal(code.includes("textureSample(occlusionTexture"), !shared); + assert.equal( + pass.bindings.some((b: any) => b.group === 1 && b.binding === 5), + !shared, + ); + await mesh.dispose(); + } +}); + +test("light variants follow first/last light lifetimes and keep hot edits rebuild-free", async () => { + const scene = await createScene(); + const material = await new PBRMaterial(scene).ready; + await detailedMesh(scene, material).ready; + const code = () => pbrPipelines(latestGraph(scene))[0].code; + assert.doesNotMatch(code(), /fn (point|spot|directional)Contribution/); + const first = await new PointLight(scene, { intensity: 0 }).ready; + assert.match(code(), /fn pointContribution/); + assert.doesNotMatch(code(), /fn (spot|directional)Contribution/); + assert.equal(code().match(/let nDotV =/g)?.length, 1); + assert.match(code(), /let diffuseColor = base.rgb \* \(1.0 - metallic\)/); + const before = testCore(scene).graphs.length; + first.intensity = 3; + first.radius = 0.4; + const second = await new PointLight(scene).ready; + await first.dispose(); + assert.equal(testCore(scene).graphs.length, before); + assert.match(code(), /fn pointContribution/); + const spot = await new SpotLight(scene).ready; + const directional = await new DirectionalLight(scene).ready; + assert.match(code(), /fn spotContribution/); + assert.match(code(), /fn directionalContribution/); + await second.dispose(); + assert.doesNotMatch(code(), /fn pointContribution/); + await spot.dispose(); + await directional.dispose(); + assert.doesNotMatch(code(), /fn (point|spot|directional)Contribution/); +}); + +test("direct canvas loadout preserves clear/depth and falls back for linear-color consumers", async () => { + const scene = await createScene({ + directToCanvas: true, + clearColor: [0.02, 0.09, 0.3, 0.5], + }); + const material = await new PBRMaterial(scene).ready; + const mesh = await detailedMesh(scene, material).ready; + const assertDirect = () => { + const graph = latestGraph(scene); + assert.equal( + graph.resources.textures.some((t: any) => t.id === "hdr"), + false, + ); + assert.equal( + graph.passes.some((p: any) => p.id === "present"), + false, + ); + const background = graph.passes.find( + (p: any) => p.id === "canvas-background", + ); + assert.deepEqual(background.depth, { resource: "depth", clear: 1 }); + assert.match( + renderPipeline(graph, "canvas-background").code, + /canvasOutput\(vec4\(0.02, 0.09, 0.3, 0.5\)\)/, + ); + const forward = forwardPasses(graph)[0]; + assert.deepEqual(forward.color, [{ resource: "canvas", load: "load" }]); + assert.deepEqual(forward.depth, { resource: "depth", load: "load" }); + assert.equal(pbrPipelines(graph)[0].fragment.targets[0].format, "canvas"); + assert.match(pbrPipelines(graph)[0].code, /unpack2x16float\(pack2x16float/); + }; + const assertHdr = () => + assert.ok(renderPipeline(latestGraph(scene), "present")); + assertDirect(); + const effect = new FXAA(scene); + await effect.ready; + assertHdr(); + await effect.dispose(); + assertDirect(); + for (const options of [{ flags: 2 }, { transmission: 0.5 }]) { + const special = await new PBRMaterial(scene, options).ready; + const extra = await detailedMesh(scene, special).ready; + assertHdr(); + await extra.dispose(); + assertDirect(); + } + const compute = new ComputePass({ + code: "@compute @workgroup_size(1) fn main() {}", + }); + await scene.addComputePass(compute); + assertHdr(); + assert.match( + pbrPipelines(latestGraph(scene))[0].code, + /fn directionalContribution/, + ); + await scene.removeComputePass(compute.id); + assertDirect(); + const custom = new ShaderMaterial(scene, { code: "custom WGSL" }); + await custom.ready; + assertHdr(); + await custom.dispose(); + assertDirect(); + const basic = await new Mesh(scene, { + vertexData: { positions: triangle.positions, indices: triangle.indices }, + }).ready; + assertHdr(); + await basic.dispose(); + assertDirect(); + const mask = await new PBRMaterial(scene, { flags: 1 }).ready; + const masked = await detailedMesh(scene, mask).ready; + assertDirect(); + assert.match( + pbrPipelines(latestGraph(scene)).find((p: any) => + p.code.includes("discard"), + ).code, + /canvasOutput/, + ); + await masked.dispose(); + await mesh.dispose(); + assertHdr(); + const ldr = await createScene({ directToCanvas: true, hdr: false }); + const ldrMaterial = await new PBRMaterial(ldr).ready; + await detailedMesh(ldr, ldrMaterial).ready; + assert.ok(renderPipeline(latestGraph(ldr), "present")); +}); diff --git a/core/Cargo.toml b/core/Cargo.toml index 25ed2d5..b5c3b4c 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -9,6 +9,7 @@ crate-type = ["cdylib"] path = "lib.rs" [dependencies] +futures-channel = "0.3" js-sys = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/core/index.js b/core/index.js index ccf315a..4dc7cda 100644 --- a/core/index.js +++ b/core/index.js @@ -1,3 +1,5 @@ +import { SharedProfile } from "./profile.js"; + const views = Object.freeze({ f32: Float32Array, u32: Uint32Array, i32: Int32Array }); export class SharedRows { @@ -49,6 +51,8 @@ export class YawnCore { #arrays = new Map(); #pending = new Map(); #profileListeners = new Set(); + #profile = new SharedProfile(); + #profileTimer; #next = 1; /** Optional WebGPU features the device has (WebGPU spelling); populated once `ready` resolves. */ features = new Set(); @@ -64,7 +68,7 @@ export class YawnCore { this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_ERROR")); this.#worker.start?.(); const offscreen = canvas.transferControlToOffscreen?.() ?? canvas; - this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen]).then(async result => { + this.ready = this.#request("init", { canvas: offscreen, arenaBytes, profileBuffer: this.#profile.buffer }, [offscreen]).then(async result => { this.#buffer = result.buffer; this.features = new Set(result.features); for (const descriptor of result.rows) this.#arrays.set( @@ -195,7 +199,17 @@ export class YawnCore { onProfile(listener) { if (typeof listener !== "function") throw new TypeError("PROFILE_LISTENER"); this.#profileListeners.add(listener); - return () => this.#profileListeners.delete(listener); + if (!this.#profileTimer) this.#profileTimer = setInterval(() => { + const stats = this.#profile.read(); + if (stats) for (const listener of this.#profileListeners) listener(stats); + }, 100); + return () => { + this.#profileListeners.delete(listener); + if (!this.#profileListeners.size) { + clearInterval(this.#profileTimer); + this.#profileTimer = undefined; + } + }; } array(name) { @@ -213,10 +227,6 @@ export class YawnCore { } #message(message) { - if (message?.type === "profile") { - for (const listener of this.#profileListeners) listener(message.stats); - return; - } const pending = this.#pending.get(message?.request); if (!pending) return; this.#pending.delete(message.request); @@ -231,6 +241,7 @@ export class YawnCore { dispose() { this.#fail("DISPOSED"); + clearInterval(this.#profileTimer); this.#profileListeners.clear(); this.#worker.terminate(); } diff --git a/core/lib.rs b/core/lib.rs index 4aabc85..d3980c9 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -302,7 +302,8 @@ impl Core { supported } - pub fn take_profile(&self) -> Option { - self.render.take_profile() + /// Installs the worker-local sink that publishes completed GPU timings into shared memory. + pub fn on_profile(&self, callback: js_sys::Function) { + self.render.on_profile(callback); } } diff --git a/core/package.json b/core/package.json index 38d068d..8f7a187 100644 --- a/core/package.json +++ b/core/package.json @@ -4,5 +4,13 @@ "description": "Rust/WASM render data and render graph core", "type": "module", "exports": "./index.js", - "files": ["index.js", "worker.js", "pkg"] + "scripts": { + "test": "node --test profile.test.js" + }, + "files": [ + "index.js", + "worker.js", + "profile.js", + "pkg" + ] } diff --git a/core/profile.js b/core/profile.js new file mode 100644 index 0000000..409287c --- /dev/null +++ b/core/profile.js @@ -0,0 +1,42 @@ +// Single-writer/single-reader latest-sample mailbox. Header: state, UTF-8 byte length. +// State 0 = empty, 1 = owned by a reader/writer, 2 = complete sample. +// Neither side waits: an unread sample can be replaced, and a busy publication is skipped. +export class SharedProfile { + constructor(buffer = new SharedArrayBuffer(1024 * 1024)) { + this.buffer = buffer; + this.header = new Int32Array(buffer, 0, 2); + this.bytes = new Uint8Array(buffer, 8); + this.encoder = new TextEncoder(); + this.decoder = new TextDecoder(); + } + + write(json) { + const bytes = this.encoder.encode(json); + if (bytes.length > this.bytes.length) return false; + const state = Atomics.load(this.header, 0); + if ( + state === 1 || + Atomics.compareExchange(this.header, 0, state, 1) !== state + ) + return false; + this.bytes.set(bytes); + this.header[1] = bytes.length; + Atomics.store(this.header, 0, 2); + return true; + } + + read() { + if (Atomics.compareExchange(this.header, 0, 2, 1) !== 2) return; + let bytes; + try { + bytes = this.bytes.slice(0, this.header[1]); + } finally { + Atomics.store(this.header, 0, 0); + } + return JSON.parse(this.decoder.decode(bytes)); + } + + clear() { + Atomics.compareExchange(this.header, 0, 2, 0); + } +} diff --git a/core/profile.test.js b/core/profile.test.js new file mode 100644 index 0000000..372d6bd --- /dev/null +++ b/core/profile.test.js @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Worker } from "node:worker_threads"; +import { SharedProfile } from "./profile.js"; +import { YawnCore } from "./index.js"; + +test("shared profiles replace unread samples and exclude overlapping reads/writes", () => { + const writer = new SharedProfile(new SharedArrayBuffer(512)); + const reader = new SharedProfile(writer.buffer); + assert.equal(reader.read(), undefined); + writer.write(JSON.stringify({ frame: 1, name: "GPU → SAB" })); + writer.write(JSON.stringify({ frame: 2, name: "light + opaque" })); + assert.deepEqual(reader.read(), { frame: 2, name: "light + opaque" }); + assert.equal(reader.read(), undefined); + Atomics.store(writer.header, 0, 1); + assert.equal(writer.write('{"frame":3}'), false); + assert.equal(reader.read(), undefined); + Atomics.store(writer.header, 0, 0); + assert.equal(writer.write(" ".repeat(513)), false); + assert.equal(writer.write('{"frame":4}'), true); + writer.clear(); + assert.equal(reader.read(), undefined); +}); + +test("concurrent shared profile publication never mixes samples", async () => { + const reader = new SharedProfile(); + const done = new Int32Array(new SharedArrayBuffer(4)); + const worker = new Worker( + ` + const {workerData} = require('node:worker_threads'); + (async () => { + const {SharedProfile} = await import(workerData.module); + const writer = new SharedProfile(workerData.buffer); + for (let frame = 1; frame <= 2000; frame++) { + const json = JSON.stringify({frame, values: Array(128).fill(frame * 3 + 7), name: 'GPU → SAB'}); + while (!writer.write(json)) await new Promise(r => setImmediate(r)); + } + Atomics.store(new Int32Array(workerData.done), 0, 1); + })(); + `, + { + eval: true, + workerData: { + module: new URL("./profile.js", import.meta.url).href, + buffer: reader.buffer, + done: done.buffer, + }, + }, + ); + const exit = new Promise((resolve, reject) => { + worker.once("error", reject); + worker.once("exit", (code) => + code ? reject(new Error(`Worker exit ${code}`)) : resolve(), + ); + }); + try { + let last = 0; + while (!Atomics.load(done, 0) || Atomics.load(reader.header, 0) === 2) { + const sample = reader.read(); + if (sample) { + assert.ok(sample.frame > last); + assert.deepEqual(sample.values, Array(128).fill(sample.frame * 3 + 7)); + assert.equal(sample.name, "GPU → SAB"); + last = sample.frame; + } + await new Promise((r) => setImmediate(r)); + } + await exit; + assert.equal(last, 2000); + } finally { + await worker.terminate(); + } +}); + +test("onProfile reads SAB without result messages and stops on unsubscribe/dispose", async (t) => { + t.mock.timers.enable({ apis: ["setInterval"] }); + const messages = []; + let receive; + let writer; + const worker = { + addEventListener(type, listener) { + if (type === "message") receive = listener; + }, + postMessage(message) { + messages.push(message.type); + writer = new SharedProfile(message.profileBuffer); + queueMicrotask(() => + receive({ + data: { + request: message.request, + result: { + buffer: new SharedArrayBuffer(64), + features: [], + rows: [], + }, + }, + }), + ); + }, + terminate() {}, + }; + const core = new YawnCore({}, { workerFactory: () => worker }); + await core.ready; + const samples = []; + const stop = core.onProfile((sample) => samples.push(sample)); + writer.write('{"frame":9,"milliseconds":1.25}'); + t.mock.timers.tick(99); + assert.equal(samples.length, 0); + t.mock.timers.tick(1); + assert.deepEqual(samples, [{ frame: 9, milliseconds: 1.25 }]); + t.mock.timers.tick(100); + assert.equal(samples.length, 1); + assert.deepEqual(messages, ["init"]); + stop(); + writer.write('{"frame":10}'); + t.mock.timers.tick(100); + assert.equal(samples.length, 1); + core.onProfile((sample) => samples.push(sample)); + core.dispose(); + t.mock.timers.tick(100); + assert.equal(samples.length, 1); +}); diff --git a/core/render_graph/gpu_resource.rs b/core/render_graph/gpu_resource.rs index 60c981a..ea01575 100644 --- a/core/render_graph/gpu_resource.rs +++ b/core/render_graph/gpu_resource.rs @@ -1,8 +1,8 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::num::NonZeroU64; -use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Arc; +use futures_channel::oneshot; use serde::de::DeserializeOwned; use serde_json::Value; @@ -31,7 +31,7 @@ pub struct GpuProfiler { pub struct ProfileMap { readback: Arc, - state: Arc, + completion: oneshot::Receiver>, labels: Vec, timestamp_period: f32, } @@ -417,16 +417,15 @@ impl GpuResources { pub fn map_profile(&self, timestamp_period: f32) -> Option { let profiler = self.profiler.as_ref()?; - let state = Arc::new(AtomicU8::new(0)); - let callback = state.clone(); + let (complete, completion) = oneshot::channel(); profiler .readback .map_async(wgpu::MapMode::Read, .., move |result| { - callback.store(if result.is_ok() { 1 } else { 2 }, Ordering::Release); + let _ = complete.send(result); }); Some(ProfileMap { readback: profiler.readback.clone(), - state, + completion, labels: self .passes .iter() @@ -438,11 +437,8 @@ impl GpuResources { } impl ProfileMap { - pub fn state(&self) -> u8 { - self.state.load(Ordering::Acquire) - } - - pub fn read(self) -> Vec<(String, f64)> { + pub async fn read(self) -> Option> { + self.completion.await.ok()?.ok()?; let bytes = self.readback.get_mapped_range(..); let values = bytes .chunks_exact(8) @@ -463,7 +459,7 @@ impl ProfileMap { .collect(); drop(bytes); self.readback.unmap(); - profile + Some(profile) } } diff --git a/core/renderer/render.rs b/core/renderer/render.rs index bb4a683..6bd7f7e 100644 --- a/core/renderer/render.rs +++ b/core/renderer/render.rs @@ -10,26 +10,13 @@ use crate::render_data::RenderData; use crate::store::{Loadout, Store}; #[wasm_bindgen(inline_js = r#" -const channel = new MessageChannel(); -const ready = []; -channel.port1.onmessage = () => ready.shift()?.(); - export function nextFrame() { return new Promise(resolve => requestAnimationFrame(resolve)); } - -export function yieldTask() { - return new Promise(resolve => { - ready.push(resolve); - channel.port2.postMessage(0); - }); -} "#)] extern "C" { #[wasm_bindgen(js_name = nextFrame)] async fn next_frame(); - #[wasm_bindgen(js_name = yieldTask)] - async fn yield_task(); } pub struct RenderLoop { @@ -42,7 +29,8 @@ pub struct RenderLoop { profiling: Cell, profile_pending: Cell, profile_after: Cell, - profile: RefCell>, + profile_callback: RefCell>, + profile_generation: Cell, } impl RenderLoop { @@ -57,7 +45,8 @@ impl RenderLoop { profiling: Cell::new(false), profile_pending: Cell::new(false), profile_after: Cell::new(0.0), - profile: RefCell::new(None), + profile_callback: RefCell::new(None), + profile_generation: Cell::new(0), } } @@ -81,13 +70,12 @@ impl RenderLoop { pub fn set_profiling(&self, enabled: bool) { self.profiling.set(enabled); self.profile_after.set(0.0); - if !enabled { - self.profile.borrow_mut().take(); - } + self.profile_generation + .set(self.profile_generation.get().wrapping_add(1)); } - pub fn take_profile(&self) -> Option { - self.profile.borrow_mut().take() + pub fn on_profile(&self, callback: js_sys::Function) { + *self.profile_callback.borrow_mut() = Some(callback); } pub fn start( @@ -152,15 +140,12 @@ impl RenderLoop { }; if let Some((profile, adapter, width, height)) = submission { control.profile_pending.set(true); - control.profile_after.set(started + 250.0); + control.profile_after.set(started + 100.0); + let generation = control.profile_generation.get(); let control = control.clone(); wasm_bindgen_futures::spawn_local(async move { - while profile.state() == 0 { - yield_task().await; - } - if profile.state() == 1 { - let passes = profile - .read() + if let Some(passes) = profile.read().await { + let passes = passes .into_iter() .map(|(name, milliseconds)| { serde_json::json!({ @@ -173,18 +158,22 @@ impl RenderLoop { .iter() .filter_map(|pass| pass["milliseconds"].as_f64()) .sum::(); - if control.profiling.get() { - *control.profile.borrow_mut() = Some( - serde_json::json!({ - "frame": frame, - "milliseconds": milliseconds, - "readbackMilliseconds": js_sys::Date::now() - started, - "adapter": adapter, - "canvas": { "width": width, "height": height }, - "passes": passes, - }) - .to_string(), - ); + if control.profiling.get() + && control.profile_generation.get() == generation + { + let sample = serde_json::json!({ + "frame": frame, + "milliseconds": milliseconds, + "readbackMilliseconds": js_sys::Date::now() - started, + "adapter": adapter, + "canvas": { "width": width, "height": height }, + "passes": passes, + }) + .to_string(); + if let Some(callback) = control.profile_callback.borrow().as_ref() { + let _ = callback + .call1(&wasm_bindgen::JsValue::NULL, &sample.into()); + } } } control.profile_pending.set(false); diff --git a/core/worker.js b/core/worker.js index 11cf0e2..e5afc99 100644 --- a/core/worker.js +++ b/core/worker.js @@ -1,7 +1,8 @@ import initWasm, { Core } from "./pkg/yawn_core.js"; +import { SharedProfile } from "./profile.js"; let core; -let profileTimer; +let profile; // While a relocation is staged, every request except its commit waits here. let awaitingCommit = false; const queue = []; @@ -15,6 +16,8 @@ async function handle(message) { if (core || !(message.canvas instanceof OffscreenCanvas)) fail("INIT"); const wasm = await initWasm(); core = new Core(message.arenaBytes); + profile = new SharedProfile(message.profileBuffer); + core.on_profile(json => profile.write(json)); await core.initialize(message.canvas); const buffer = wasm.memory.buffer; if (!(buffer instanceof SharedArrayBuffer)) fail("WASM_MEMORY_NOT_SHARED"); @@ -70,11 +73,7 @@ async function handle(message) { break; case "set-profiler": result = core.set_profiler(Boolean(message.enabled)); - clearInterval(profileTimer); - profileTimer = result && message.enabled ? setInterval(() => { - const stats = core.take_profile(); - if (stats) postMessage({ type: "profile", stats: JSON.parse(stats) }); - }, 250) : undefined; + profile.clear(); break; default: fail("MESSAGE"); diff --git a/coredocs/reference/worker.md b/coredocs/reference/worker.md index 86c461a..da86e4a 100644 --- a/coredocs/reference/worker.md +++ b/coredocs/reference/worker.md @@ -4,7 +4,7 @@ All fields below are top-level beside `type` and `request`. Except for `init` an | `type` | Fields | Transfer list | Success `result` | Constraints / effects | |---|---|---|---|---| -| `init` | `canvas: OffscreenCanvas`, `arenaBytes: number` | `[canvas]` | `{ buffer: SharedArrayBuffer, rows: Descriptor[], features: string[] }` | Once only; initializes WASM/WebGPU. `features` lists optional device features that were available (`timestamp-query`, `indirect-first-instance`). | +| `init` | `canvas: OffscreenCanvas`, `arenaBytes: number`, `profileBuffer: SharedArrayBuffer` | `[canvas]` | `{ buffer: SharedArrayBuffer, rows: Descriptor[], features: string[] }` | Once only; initializes WASM/WebGPU. `features` lists optional device features that were available (`timestamp-query`, `indirect-first-instance`). `profileBuffer` is shared, not transferred. | | `create-rows` | `name`, `rows`, `stride`, `format` | — | `{ rows: descriptor, commit: boolean }` | Nonempty name; rows > 0; stride ≥16 and multiple of 16; format f32/u32/i32. Existing name may only retain format/stride and grow. `commit: true` means the rows moved: copy the old bytes into the descriptor and send `commit-rows`. | | `create-rows-batch` | `rows: Array<{name,rows,stride,format}>` | — | `{ rows: descriptor[] (input order), commit: string[] }` | Nonempty. Sequential; in-place creations/growths survive a later failure, staged relocations are discarded. `commit` lists the arrays that moved. Active GPU resources refresh once for in-place growth. | | `commit-rows` | `names: string[]` | — | `undefined` | Publishes staged relocations after the caller copied them. Must name every staged array. Frees the old rows, refreshes bound GPU buffers once, sets `sabDirty`. While a relocation is staged the worker queues every other request until this arrives. | @@ -23,25 +23,26 @@ All fields below are top-level beside `type` and `request`. Except for `init` an Rust/WASM numeric conversion applies to `u32` fields; callers should send finite nonnegative integers in range rather than rely on coercion. -## Profiler events +## Shared profiler results -When enabled and supported, the worker polls every 250 ms and may send an unsolicited message with **no request field**: +When enabled and supported, GPU timestamp queries are sampled at most once every 100 ms, with only one readback in flight. Completion of `map_async` wakes an awaiting future; neither the worker nor render loop polls for GPU completion. Rendering continues independently. + +Completed results are published into `profileBuffer`, not sent as worker messages. `YawnCore.onProfile` reads this buffer locally every 100 ms while listeners exist and delivers the same stats shape: ```js { - type: "profile", - stats: { frame: 42, milliseconds: 0.31, readbackMilliseconds: 4.8, adapter: "Adapter name · DeviceType · Backend", canvas: { width: 1280, height: 720 }, passes: [{ name: "triangle", milliseconds: 0.31 }] - } } ``` -One pass timing corresponds to a compiled execution; compatible adjacent render passes are merged into one execution labelled `first … last (N draws)`. Samples are throttled and asynchronous, so they are diagnostics, not one event per frame. Disabling clears pending published statistics and the worker polling interval. +One pass timing corresponds to a compiled execution; compatible adjacent render passes are merged into one execution labelled `first … last (N draws)`. `milliseconds` is GPU time; `readbackMilliseconds` includes the asynchronous wait. Disabling clears published statistics and suppresses results from older profiling sessions. + +The default mailbox is a separate 1 MiB SAB. Its first two `Int32` values are ownership state and UTF-8 JSON length; the payload starts at byte 8. State 0 means empty, 1 means owned by a reader or writer, and 2 means a complete sample. Both sides claim ownership using `Atomics.compareExchange` and never wait. An unread sample is replaced by the latest sample; a publication during a read, or larger than the payload capacity, is skipped. The reader copies the bytes before releasing ownership, so it cannot observe a partially written result. Initialization and profiler enable/disable remain ordinary control requests; results never use `postMessage`. ## Texture lifecycle diff --git a/package.json b/package.json index 40fb688..f222e6d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "server": "go run ./server", "coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}", "build:coredocs": "vitepress build coredocs", - "test": "npm run test:handles && npm run test:fxnode-rg", + "test": "npm test --workspace @yawn/core && npm run test:handles && npm run test:fxnode-rg", "test:handles": "npm test --workspace @yawn/handles", "test:fxnode-rg": "npm test --workspace @yawn/fxnode-rg", "test:handles:screenshots": "npm run test:screenshots --workspace @yawn/handles" diff --git a/server/build.mjs b/server/build.mjs index caacc03..a723f59 100644 --- a/server/build.mjs +++ b/server/build.mjs @@ -34,12 +34,20 @@ const fxNodeImports = { }, }; +const coreWorker = await build({ + ...browser, + entryPoints: [path.join(root, "core", "worker.js")], + external: ["./pkg/yawn_core.js"], + write: false, +}); +const coreWorkerSource = coreWorker.outputFiles[0].text; + const workerFiles = new Map([ [ path.join(root, "core", "index.js"), { url: "./worker.js", - source: await fs.readFile(path.join(root, "core", "worker.js"), "utf8"), + source: coreWorkerSource, }, ], [ @@ -113,15 +121,7 @@ await Promise.all([ entryPoints: [path.join(fxNodeSource, "worker", "fxnode.worker.ts")], outfile: path.join(output, "fxnode-worker.js"), }), - build({ - bundle: false, - entryPoints: [path.join(root, "core", "worker.js")], - format: "esm", - legalComments: "none", - minify: true, - outfile: path.join(output, "worker.js"), - target: "es2022", - }), + fs.writeFile(path.join(output, "worker.js"), coreWorkerSource), build({ ...browser, entryPoints: [ diff --git a/server/web/assets/gltf.js b/server/web/assets/gltf.js index c046d8e..0f57e8a 100644 --- a/server/web/assets/gltf.js +++ b/server/web/assets/gltf.js @@ -224,6 +224,7 @@ async function openFile(file) { scene = new Scene(canvas, { arenaBytes: 384 * 1024 * 1024, hdr: true, + directToCanvas: true, clearColor: [0.025, 0.035, 0.045, 1], }); await scene.ready; @@ -266,16 +267,20 @@ async function openFile(file) { ); await Promise.all([...lights.map((light) => light.ready), camera.ready]); - elements.renderValue.textContent = "— ms"; - elements.renderTime.hidden = false; - const stopProfile = scene.core.onProfile((profile) => { - elements.renderValue.textContent = `${profile.milliseconds.toFixed(2)} ms`; - }); - const profiling = await scene.core.setProfiler(true); - if (!profiling) - elements.renderTime.title = - "GPU timestamp queries are not supported by this adapter."; - else elements.renderTime.removeAttribute("title"); + // Diagnostic A/B: omit queries and readback, not just the timing badge. + let stopProfile; + if (new URLSearchParams(location.search).get("profile") !== "off") { + elements.renderValue.textContent = "— ms"; + elements.renderTime.hidden = false; + stopProfile = scene.core.onProfile((profile) => { + elements.renderValue.textContent = `${profile.milliseconds.toFixed(2)} ms`; + }); + const profiling = await scene.core.setProfiler(true); + if (!profiling) + elements.renderTime.title = + "GPU timestamp queries are not supported by this adapter."; + else elements.renderTime.removeAttribute("title"); + } activeViewer = { camera,