import { YawnCore } from "@yawn/core"; import type { ComputePass, GraphBuffer, GraphSampler, GraphTexture, } from "./ComputePass"; type RowFormat = "f32" | "u32" | "i32"; type MeshLike = { id: number; geometryId: number; faceMaterials: ReadonlyMap; }; type VertexData = Float32Array | Uint32Array; /** One geometry: a span of interleaved `vertices` rows plus a span of `indices` rows. */ type Geometry = { data: Map; /** Serialises published mutations of this geometry's spans. */ pending: Promise; references: number; vertexRow: number; vertexCount: number; indexRow: number; indexCount: number; }; /** Interleaved vertex row: 20 floats = 80 bytes; lanes match the pipeline attribute offsets. */ const vertexLanes: Record = { positions: [0, 3], normals: [4, 3], uvs: [8, 2], uvs2: [10, 2], tangents: [12, 4], colors: [16, 4], }; const vertexWidth = 20; const vertexAttributes = [ { format: "float32x3", offset: 0, shaderLocation: 0 }, { format: "float32x3", offset: 16, shaderLocation: 1 }, { format: "float32x2", offset: 32, shaderLocation: 2 }, { format: "float32x4", offset: 48, shaderLocation: 3 }, { format: "float32x4", offset: 64, shaderLocation: 4 }, { format: "float32x2", offset: 40, shaderLocation: 5 }, ]; type ShaderLike = { id: number; code: string; vertexEntry: string; fragmentEntry: string; }; type PostProcessState = { id: string; kind: string; options: Record; }; type TextureState = GraphTexture & { number: number; source?: string | ImageBitmap; sampler?: Omit; }; const rows = [ ["nodes", 16, "u32"], ["nodePositions", 16, "f32"], ["nodeRotors", 16, "f32"], ["nodeScales", 16, "f32"], ["meshInfo", 16, "u32"], ["bounds", 32, "f32"], ["cameras", 80, "f32"], ["cameraMatrices", 80, "f32"], ["materials", 48, "f32"], ["materialTextures", 64, "u32"], ["materialExtensions", 112, "f32"], ["materialTextureInfo", 416, "f32"], ["pointLights", 32, "f32"], ["rectAreaLights", 48, "f32"], ["spotLights", 32, "f32"], ["directionalLights", 32, "f32"], ["ambientLights", 16, "f32"], ["sceneAccent", 16, "f32"], ["vertices", 80, "f32"], ["indices", 16, "u32"], ] as const; const compactLightLimit = 500; const compactPointStart = 11; const compactSpotCount = compactPointStart + compactLightLimit; const compactSpotStart = compactSpotCount + 1; const compactAmbientStart = compactSpotStart + compactLightLimit; const clusterShader = /* wgsl */ ` @group(0) @binding(0) var pointLights: array>; @group(0) @binding(1) var rectLights: array>; @group(0) @binding(2) var spotLights: array>; @group(0) @binding(3) var directionalLights: array>; @group(0) @binding(4) var ambientLights: array>; @group(0) @binding(5) var clusters: array; @compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3) { if (id.x != 0u) { return; } var count = 0u; var pointCount = 0u; var spotCount = 0u; var light = vec3(0.0); var ambientLight = vec3(0.0); var pointLight = vec3(0.0); var spotLight = vec3(0.0); for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) { let enabled = pointLights[i * 2u + 1u].y; count += select(0u, 1u, enabled != 0.0); if (enabled != 0.0) { if (pointCount < ${compactLightLimit}u) { clusters[${compactPointStart}u + pointCount] = i; } pointCount += 1u; } let contribution = pointLights[i * 2u].rgb * pointLights[i * 2u].a * enabled * 0.02; light += contribution; pointLight += contribution; } for (var i = 0u; i < arrayLength(&rectLights) / 3u; i++) { let enabled = rectLights[i * 3u + 1u].z; count += select(0u, 1u, enabled != 0.0); let contribution = rectLights[i * 3u].rgb * rectLights[i * 3u].a * enabled * 0.02; light += contribution; ambientLight += contribution; } for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) { let enabled = spotLights[i * 2u + 1u].w; count += select(0u, 1u, enabled != 0.0); if (enabled != 0.0) { if (spotCount < ${compactLightLimit}u) { clusters[${compactSpotStart}u + spotCount] = i; } spotCount += 1u; } let contribution = spotLights[i * 2u].rgb * spotLights[i * 2u].a * enabled * 0.02; light += contribution; spotLight += contribution; } for (var i = 0u; i < arrayLength(&directionalLights) / 2u; i++) { let enabled = directionalLights[i * 2u + 1u].x; count += select(0u, 1u, enabled != 0.0); light += directionalLights[i * 2u].rgb * directionalLights[i * 2u].a * enabled * 0.1; } for (var i = 0u; i < arrayLength(&ambientLights); i++) { count += select(0u, 1u, ambientLights[i].a != 0.0); let contribution = ambientLights[i].rgb * ambientLights[i].a; light += contribution; ambientLight += contribution; } clusters[0] = count; clusters[1] = bitcast(light.r); clusters[2] = bitcast(light.g); clusters[3] = bitcast(light.b); clusters[4] = bitcast(pointLight.r); clusters[5] = bitcast(pointLight.g); clusters[6] = bitcast(pointLight.b); clusters[7] = bitcast(spotLight.r); clusters[8] = bitcast(spotLight.g); clusters[9] = bitcast(spotLight.b); clusters[10] = pointCount; clusters[${compactSpotCount}] = spotCount; clusters[${compactAmbientStart}] = bitcast(ambientLight.r); clusters[${compactAmbientStart + 1}] = bitcast(ambientLight.g); clusters[${compactAmbientStart + 2}] = bitcast(ambientLight.b); }`; const basicForwardShader = /* wgsl */ ` struct Accent { color: vec4 } struct VertexOutput { @invariant @builtin(position) position: vec4, @location(0) normal: vec3, @location(1) @interpolate(flat) mesh: u32, @location(2) @interpolate(flat) material: u32, } @group(0) @binding(0) var clusters: array; @group(0) @binding(1) var accent: Accent; @group(0) @binding(2) var positions: array>; @group(0) @binding(3) var rotors: array>; @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 cameraMatrices: array>; fn rotate(rotor: vec4, value: vec3) -> vec3 { return value + 2.0 * cross(rotor.xyz, cross(rotor.xyz, value) + rotor.w * value); } @vertex fn vertex(@location(0) point: vec3, @builtin(instance_index) packed: u32) -> VertexOutput { let instance = packed & 65535u; let visible = meshInfo[instance * 4u + 2u]; let transformed = rotate(rotors[instance], point * scales[instance].xyz) + positions[instance].xyz; var clip = vec4(transformed, 1.0); 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); output.normal = normalize(rotate(rotors[instance], vec3(0.0, 0.0, 1.0))); output.mesh = instance; output.material = packed >> 16u; return output; } @fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { let fallback = meshInfo[input.mesh * 4u + 1u]; let material = select(fallback, input.material - 1u, input.material != 0u); let base = materials[material * 3u]; let properties = materials[material * 3u + 1u]; let clusterLight = vec3(bitcast(clusters[1]), bitcast(clusters[2]), bitcast(clusters[3])); let color = base.rgb * clusterLight * accent.color.rgb * mix(1.0, 1.1, properties.x); return vec4(color, base.a); }`; function pbrShader( mask: number, flags: number, transmission: boolean, textureInfo: ArrayLike, extensionInfo: ArrayLike, sharedOcclusion: boolean, lightMask: number, directToCanvas: boolean, ) { const names = [ "baseTexture", "materialTexture", "normalTexture", "emissiveTexture", "occlusionTexture", "transmissionTexture", "thicknessTexture", "clearcoatTexture", "iridescenceTexture", "iridescenceThicknessTexture", "anisotropyTexture", "specularTexture", "specularColorTexture", ]; const texture = (slot: number) => mask & (1 << slot); const number = (value: number) => Number.isFinite(value) ? Number.isInteger(value) ? `${value}.0` : `${value}` : "0.0"; const textureUv = (slot: number) => { const at = slot * 8; const source = textureInfo[at + 6] > 0.5 ? "input.uv2" : "input.uv"; if ( textureInfo[at] === 0 && textureInfo[at + 1] === 0 && textureInfo[at + 2] === 1 && textureInfo[at + 3] === 1 && textureInfo[at + 4] === 0 && textureInfo[at + 5] === 1 ) return source; const x = `(${source}.x * ${number(textureInfo[at + 2])})`; const y = `(${source}.y * ${number(textureInfo[at + 3])})`; return `vec2(${number(textureInfo[at + 5])} * ${x} - ${number(textureInfo[at + 4])} * ${y}, ${number(textureInfo[at + 4])} * ${x} + ${number(textureInfo[at + 5])} * ${y}) + vec2(${number(textureInfo[at])}, ${number(textureInfo[at + 1])})`; }; const sample = (slot: number, fallback = "vec4(1.0)") => texture(slot) ? `textureSample(${names[slot]}, materialSampler, ${textureUv(slot)})` : fallback; const normalTexture = texture(2); const alphaMode = flags & 3; const doubleSided = flags & 4; const unlit = flags & 8; const specularGlossiness = flags & 16; const clearcoat = extensionInfo[8] > 0; const sheen = extensionInfo[12] > 0 || extensionInfo[13] > 0 || extensionInfo[14] > 0; const iridescence = extensionInfo[16] > 0; const anisotropy = extensionInfo[20] > 0; const attenuation = transmission && extensionInfo[3] > 0; const ior = Math.max(extensionInfo[1], 1); const dielectric = ((ior - 1) / (ior + 1)) ** 2; return /* wgsl */ ` struct VertexOutput { @invariant @builtin(position) position: vec4, @location(0) world: vec3, @location(1) normal: vec3, @location(2) uv: vec2, @location(3) tangent: vec4, @location(4) color: vec4, @location(5) uv2: vec2, @location(6) @interpolate(flat) material: u32, } @group(0) @binding(0) var clusters: array; @group(0) @binding(1) var accent: vec4; @group(0) @binding(2) var positions: array>; @group(0) @binding(3) var rotors: array>; @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 cameraMatrices: 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) && !(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 { return value + 2.0 * cross(rotor.xyz, cross(rotor.xyz, value) + rotor.w * value); } @vertex fn vertex( @location(0) point: vec3, @location(1) localNormal: vec3, @location(2) uv: vec2, @location(3) localTangent: vec4, @location(4) color: vec4, @location(5) uv2: vec2, @builtin(instance_index) packed: u32, ) -> VertexOutput { let instance = packed & 65535u; let scale = scales[instance].xyz; let world = rotate(rotors[instance], point * scale) + positions[instance].xyz; var clip = vec4(world, 1.0); 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); output.world = world; output.normal = normalize(rotate(rotors[instance], localNormal / scale)); output.uv = uv; output.tangent = vec4(normalize(rotate(rotors[instance], localTangent.xyz * scale)), localTangent.w); output.color = color; output.uv2 = uv2; output.material = (packed >> 16u) - 1u; return output; } fn pow5(value: f32) -> f32 { let squared = value * value; return squared * squared * value; } 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 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); let alpha = roughness * roughness; let alpha2 = alpha * alpha; let denominator = nDotH * nDotH * (alpha2 - 1.0) + 1.0; let distribution = alpha2 / max(3.14159265 * denominator * denominator, 0.0001); let geometryV = nDotV / mix(nDotV, 1.0, alpha * 0.5); let geometryL = nDotL / mix(nDotL, 1.0, alpha * 0.5); let specular = distribution * geometryV * geometryL * fresnel / max(4.0 * nDotV * nDotL, 0.0001); let diffuse = diffuseColor * (vec3(1.0) - fresnel); return (diffuse + specular) * radiance * nDotL; } ${ 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); } let offset = positions[index].xyz - world; let distance = length(offset); 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, diffuseColor, nDotV, sourceRoughness, f0); } ` : "" } ${ 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); } let fromLight = world - positions[index].xyz; let distance = length(fromLight); let direction = normalize(rotate(rotors[index], vec3(0.0, 0.0, -1.0))); 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, diffuseColor, nDotV, roughness, f0); } ` : "" } ${ 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, diffuseColor, nDotV, roughness, f0); } ` : "" } ${directToCanvas ? canvasOutputShader : ""} @fragment fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @location(0) vec4 { let factor = materials[input.material * 3u]; let properties = materials[input.material * 3u + 1u]; let extra = materials[input.material * 3u + 2u]; let extension0 = vec4(${Array.from(extensionInfo).slice(0, 4).map(number).join(", ")}); let extension1 = vec4(${Array.from(extensionInfo).slice(4, 8).map(number).join(", ")}); let extension2 = vec4(${Array.from(extensionInfo).slice(8, 12).map(number).join(", ")}); let extension3 = vec4(${Array.from(extensionInfo).slice(12, 16).map(number).join(", ")}); let extension4 = vec4(${Array.from(extensionInfo).slice(16, 20).map(number).join(", ")}); let extension5 = vec4(${Array.from(extensionInfo).slice(20, 24).map(number).join(", ")}); let extension6 = vec4(${Array.from(extensionInfo).slice(24, 28).map(number).join(", ")}); let base = factor * input.color * ${sample(0)}; ${alphaMode === 1 ? "if (base.a < extra.z) { discard; }" : ""} let packedMaterial = ${sample(1)}; let metallic = clamp(properties.x * ${specularGlossiness ? "0.0" : texture(1) ? "packedMaterial.b" : "1.0"}, 0.0, 1.0); ${anisotropy ? `let anisotropyAmount = clamp(extension5.x * ${sample(10)}.b, 0.0, 1.0);` : ""} let roughness = clamp(${specularGlossiness ? `1.0 - (1.0 - properties.y) * ${texture(1) ? "packedMaterial.a" : "1.0"}` : `properties.y * ${texture(1) ? "packedMaterial.g" : "1.0"}`}${anisotropy ? " * mix(1.0, 0.7, anisotropyAmount)" : ""}, 0.04, 1.0); var normal = normalize(input.normal); ${doubleSided ? "normal *= select(-1.0, 1.0, frontFacing);" : ""} ${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)"}; ${ iridescence ? `let iridescenceAmount = clamp(extension4.x * ${sample(8)}.r, 0.0, 1.0); let iridescenceThickness = mix(extension4.z, extension4.w, ${sample(9)}.g); let iridescencePhase = iridescenceThickness * 0.025 + (1.0 - nDotV) * 6.0; let iridescenceTint = 0.12 + 0.88 * (vec3(0.5) + vec3(0.5) * cos(vec3(iridescencePhase, iridescencePhase + 2.0944, iridescencePhase + 4.1888))); let iridescenceLevel = max(max(f0.r, f0.g), max(f0.b, 0.04)); f0 = mix(f0, iridescenceTint * iridescenceLevel, iridescenceAmount * (1.0 - roughness * 0.5));` : "" } 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, diffuseColor, nDotV, roughness, f0); } } else { for (var light = 0u; light < arrayLength(&pointLights) / 2u; light++) { 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, diffuseColor, nDotV, roughness, f0); } } else { for (var light = 0u; light < arrayLength(&spotLights) / 2u; light++) { directLight += spotContribution(light, normal, view, input.world, diffuseColor, nDotV, roughness, f0); } } ` : "" } ${ 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));" : ""} ${ clearcoat ? `let clearcoatFresnel = 0.04 + 0.96 * fresnelPower; let clearcoat = extension2.x * ${sample(7)}.r * clearcoatFresnel;` : "" } var color = base.rgb * ambient * occlusion + directLight${sheen ? " + directLight * sheen" : ""}${clearcoat ? " + directLight * clearcoat" : ""} + emissive; ${unlit ? "color = base.rgb + emissive;" : ""} ${ transmission ? `let transmissionAmount = clamp(extension0.x * ${sample(5)}.r, 0.0, 1.0); let thickness = max(extension0.z * ${sample(6)}.g, 0.0); let screenUv = input.position.xy / vec2(textureDimensions(sceneColor)); let refractionOffset = normal.xy * ${number(1 - 1 / ior)} * mix(0.006, 0.035, clamp(thickness, 0.0, 1.0)); let refractedUv = clamp(screenUv + refractionOffset, vec2(0.001), vec2(0.999)); let transmissionBlur = vec2(0.02078461 * roughness); var transmitted = textureSampleLevel(sceneColor, sceneSampler, refractedUv, 0.0).rgb * 0.2; transmitted += textureSampleLevel(sceneColor, sceneSampler, clamp(refractedUv + transmissionBlur, vec2(0.001), vec2(0.999)), 0.0).rgb * 0.2; transmitted += textureSampleLevel(sceneColor, sceneSampler, clamp(refractedUv - transmissionBlur, vec2(0.001), vec2(0.999)), 0.0).rgb * 0.2; transmitted += textureSampleLevel(sceneColor, sceneSampler, clamp(refractedUv + vec2(transmissionBlur.x, -transmissionBlur.y), vec2(0.001), vec2(0.999)), 0.0).rgb * 0.2; transmitted += textureSampleLevel(sceneColor, sceneSampler, clamp(refractedUv + vec2(-transmissionBlur.x, transmissionBlur.y), vec2(0.001), vec2(0.999)), 0.0).rgb * 0.2; ${ attenuation ? `{ let travel = thickness / max(abs(dot(normal, view)), 0.15); transmitted *= pow(clamp(extension1.rgb, vec3(0.001), vec3(1.0)), vec3(travel / extension0.w)); }` : "" } let normalizedTransmissionTint = base.rgb / max(max(base.r, base.g), max(base.b, 0.001)); transmitted *= normalizedTransmissionTint; color = mix(color, transmitted, transmissionAmount);` : "" } return ${directToCanvas ? "canvasOutput(vec4(color * accent.a, 1.0))" : `vec4(color * accent.a, ${alphaMode === 2 ? "base.a" : "1.0"})`}; }`; } const clearShader = /* wgsl */ ` @vertex fn vertex() -> @builtin(position) vec4 { return vec4(0.0); } @fragment fn fragment() -> @location(0) vec4 { return vec4(0.0); }`; const fullscreenVertex = /* wgsl */ ` struct VertexOutput { @builtin(position) position: vec4, @location(0) uv: vec2, } @vertex fn vertex(@builtin(vertex_index) index: u32) -> VertexOutput { let points = array(vec2(-1.0, -3.0), vec2(3.0, 1.0), vec2(-1.0, 1.0)); var output: VertexOutput; output.position = vec4(points[index], 0.0, 1.0); output.uv = points[index] * vec2(0.5, -0.5) + vec2(0.5); return output; }`; const copyShader = `${fullscreenVertex} @group(0) @binding(0) var source: texture_2d; @group(0) @binding(1) var sourceSampler: sampler; @fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { return textureSample(source, sourceSampler, input.uv); }`; function effectFragment(kind: string, options: Record) { const amount = Number(options.amount ?? options.exposure ?? 1); const safeAmount = Number.isFinite(amount) ? amount : 1; const body: Record = { ssao: `let value = textureSample(source, sourceSampler, input.uv); return vec4(value.rgb * ${Math.max(0, 1 - safeAmount * 0.2)}, value.a);`, fxaa: `let size = vec2(textureDimensions(source)); let pixel = 1.0 / size; let center = textureSample(source, sourceSampler, input.uv); let around = textureSample(source, sourceSampler, input.uv + vec2(pixel.x, 0.0)) + textureSample(source, sourceSampler, input.uv - vec2(pixel.x, 0.0)) + textureSample(source, sourceSampler, input.uv + vec2(0.0, pixel.y)) + textureSample(source, sourceSampler, input.uv - vec2(0.0, pixel.y)); return mix(center, around * 0.25, 0.35);`, colorGrading: `let value = textureSample(source, sourceSampler, input.uv); return vec4(pow(max(value.rgb * ${safeAmount}, vec3(0.0)), vec3(1.0 / 2.2)), value.a);`, dynamicExposure: `let value = textureSample(source, sourceSampler, input.uv); return vec4(value.rgb * ${safeAmount}, value.a);`, silhouette: `let size = vec2(textureDimensions(source)); let pixel = 1.0 / size; let value = textureSample(source, sourceSampler, input.uv); let edge = length(value.rgb - textureSample(source, sourceSampler, input.uv + pixel).rgb); return vec4(mix(value.rgb, vec3(0.0), smoothstep(0.08, 0.2, edge)), value.a);`, edges: `let size = vec2(textureDimensions(source)); let pixel = 1.0 / size; let value = textureSample(source, sourceSampler, input.uv); let dx = length(value.rgb - textureSample(source, sourceSampler, input.uv + vec2(pixel.x, 0.0)).rgb); let dy = length(value.rgb - textureSample(source, sourceSampler, input.uv + vec2(0.0, pixel.y)).rgb); return vec4(vec3(max(dx, dy) * ${safeAmount}), value.a);`, }; return `${fullscreenVertex} @group(0) @binding(0) var source: texture_2d; @group(0) @binding(1) var sourceSampler: sampler; @fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { ${body[kind] ?? "return textureSample(source, sourceSampler, input.uv);"} }`; } 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) { return `${fullscreenVertex} @group(0) @binding(0) var source: texture_2d; @group(0) @binding(1) var sourceSampler: sampler; ${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(${toneMapping(toneMap)}, ${composite ? "1.0" : "value.a"}); }`; } function encode(value: unknown): string { if (value === null || typeof value === "boolean" || typeof value === "number") return String(value); if (typeof value === "string") return JSON.stringify(value); if (Array.isArray(value)) return `(array${value.map((item) => ` ${encode(item)}`).join("")})`; if (value && value.constructor === Object) return `(object${Object.keys(value as object) .sort() .map( (key) => ` (field ${JSON.stringify(key)} ${encode((value as Record)[key])})`, ) .join("")})`; throw new TypeError("Render graph values must be plain data"); } function serialize(graph: object) { return `(yawn-graph 1 ${encode(graph)})`; } const typedArrayMutators = new Set([ "copyWithin", "fill", "reverse", "set", "sort", ]); /** The conventional single-loadout scene layer; hot values always remain direct SAB writes. */ 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; #computePasses = new Map(); #meshes = new Map(); #shaders = new Map(); #effects = new Map(); #textures = new Map(); #geometry = new Map(); #nextGeometry = 1; #nextTexture = 0; #graphBatchDepth = 0; #graphBatchDirty = false; #inhibits = 0; #batchInhibits = 0; /** Indirect draw buckets: pipeline + bind-set key → `draws.`, and each bucket's row capacity. */ #buckets = new Map(); #bucketCapacity: number[] = []; #writeBatchDepth = 0; #writeBatchDirty = false; #writeBatchBundleDirty = false; #signals?: Float32Array; #arrays = new WeakMap(); #views = new WeakMap(); #lights = { point: new Set(), spot: new Set(), directional: new Set(), }; constructor( canvas: HTMLCanvasElement, options: { arenaBytes?: number; debug?: boolean; fps?: number; hdr?: boolean; directToCanvas?: boolean; clearColor?: ArrayLike; } = {}, ) { const clearColor = Array.from(options.clearColor ?? [0, 0, 0, 1]); if ( clearColor.length !== 4 || clearColor.some((value) => !Number.isFinite(value)) ) 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, debug: options.debug, }); this.ready = this.#initialize(options.fps); } async #initialize(fps?: number) { await this.core.ready; if (!this.core.features.has("indirect-first-instance")) throw new Error( "INDIRECT_FIRST_INSTANCE: required by Scene draw buckets", ); this.#signals = this.core.array("signals").row(0); for (const [name, stride, format] of rows) await this.core.createRows({ name, rows: 1, stride, format }); await this.core.createRows({ name: "clusters", rows: 256, stride: 16, format: "u32", }); this.core.array("nodeRotors").write(0, [0, 0, 0, 1]); this.core.array("nodeScales").write(0, [1, 1, 1, 0]); this.core.array("sceneAccent").write(0, [1, 1, 1, 1]); const material = await this.core.allocateObject("materials"); this.core .array("materials") .write(material, [1, 1, 1, 1, 0, 0.7, 0, 0, 0, 1, 0.5, 0]); this.core .array("materialExtensions") .write( material, [ 0, 1.5, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1.3, 100, 400, 0, 0, 0, 0, 1, 1, 1, 0, ], ); const textureInfo = this.core.array("materialTextureInfo").row(material); for (let slot = 0; slot < 13; slot++) { textureInfo[slot * 8 + 2] = 1; textureInfo[slot * 8 + 3] = 1; textureInfo[slot * 8 + 5] = 1; } if (fps !== undefined) await this.core.setFps(fps); await this.#compileRenderGraph(); return this; } array(name: string) { const array = this.core.array(name); const current = this.#arrays.get(array); if (current) return current as typeof array; let proxy: typeof array; proxy = new Proxy(array, { get: (target, property) => { if (property === "row") return (index: number) => this.#mutable(target.row(index)); if (property === "view") return this.#mutable(target.view); if (property === "write") return (index: number, values: ArrayLike) => { target.write(index, values); this.markDirty(); return proxy; }; const value = Reflect.get(target, property, target); return typeof value === "function" ? value.bind(target) : value; }, }); this.#arrays.set(array, proxy); return proxy; } #mutable(view: T): T { const current = this.#views.get(view); if (current) return current as T; let proxy: T; proxy = new Proxy(view, { get: (target, property) => { if (property === "subarray") return (begin?: number, end?: number) => this.#mutable(target.subarray(begin, end) as T); const value = Reflect.get(target, property, target); if (typeof value !== "function") return value; if (property === "constructor") return value; if (!typedArrayMutators.has(String(property))) return value.bind(target); return (...arguments_: unknown[]) => { const result = Reflect.apply(value, target, arguments_); this.markDirty(); return result === target ? proxy : result; }; }, set: (target, property, value) => { const written = Reflect.set(target, property, value, target); if (written) this.markDirty(); return written; }, }); this.#views.set(view, proxy); return proxy; } markDirty(bundle = false) { if (this.#writeBatchDepth) { this.#writeBatchDirty = true; this.#writeBatchBundleDirty ||= bundle; return; } if (!this.#signals) return; this.#signals[5] = 1; if (bundle) this.#signals[6] = 1; } /** Defers the dirty signal until a synchronous group of SAB writes is complete. */ batchWrites(operation: () => T) { this.#writeBatchDepth++; try { return operation(); } finally { this.#writeBatchDepth--; if (!this.#writeBatchDepth && this.#writeBatchDirty) { const bundle = this.#writeBatchBundleDirty; this.#writeBatchDirty = false; this.#writeBatchBundleDirty = false; this.markDirty(bundle); } } } async ensureRows( name: string, rowCount: number, stride: number, format: RowFormat, ) { await this.core.ready; try { const current = this.core.array(name); if (current.stride !== stride || current.format !== format) throw new Error(`ROW_LAYOUT: ${name}`); if (current.rows >= rowCount) return current; } catch (error) { if ( !(error instanceof Error) || !error.message.startsWith("UNKNOWN_ARRAY") ) throw error; } return this.core.createRows({ name, rows: Math.max(1, rowCount), stride, format, }); } /** * Grows the shared arrays ahead of a bulk load so the objects created afterwards never * relocate them: `nodes`/`materials` count objects, `vertices`/`indices` count elements. */ async reserve(additional: { nodes?: number; materials?: number; vertices?: number; indices?: number; }) { await this.ready; const counts = { nodes: additional.nodes ?? 0, materials: additional.materials ?? 0, vertices: additional.vertices ?? 0, indices: Math.ceil((additional.indices ?? 0) / 4), }; if ( !Object.values(counts).every( (value) => Number.isInteger(value) && value >= 0, ) ) throw new RangeError("additional"); const families: [keyof typeof counts, readonly (typeof rows)[number][]][] = [ ["nodes", rows.slice(0, 6)], ["materials", rows.slice(8, 12)], ["vertices", rows.slice(18, 19)], ["indices", rows.slice(19, 20)], ]; const growth = families .flatMap(([primary, members]) => { const capacity = this.core.array(primary).rows + counts[primary]; return members.map(([name, stride, format]) => ({ name, rows: capacity, stride, format, })); }) .filter((request) => this.core.array(request.name).rows < request.rows); if (growth.length) await this.core.createRowsBatch(growth); } async batchGraphUpdates(operation: () => T | Promise) { await this.ready; this.#graphBatchDepth++; try { return await operation(); } finally { this.#graphBatchDepth--; if (!this.#graphBatchDepth) { try { if (this.#graphBatchDirty) { this.#graphBatchDirty = false; await this.updateRenderGraph(); } } finally { for (; this.#batchInhibits; this.#batchInhibits--) this.#releaseRender(); } } } } async allocateNode() { await this.ready; const id = await this.core.allocateObject("nodes"); await this.#growSiblings("nodes", rows.slice(1, 6)); this.array("nodeRotors").write(id, [0, 0, 0, 1]); this.array("nodeScales").write(id, [1, 1, 1, 0]); this.array("nodes").write(id, [1, 0, 0, 0]); return id; } async releaseNode(id: number) { for (const name of [ "nodes", "nodePositions", "nodeRotors", "nodeScales", "meshInfo", "bounds", ]) this.array(name).row(id).fill(0); await this.core.deleteObject("nodes", id); } async allocateMaterial() { await this.ready; const id = await this.core.allocateObject("materials"); await this.#growSiblings("materials", rows.slice(9, 12)); return id; } /** Grows the arrays indexed by `primary`'s IDs to its (geometric) capacity in one batch. */ async #growSiblings( primary: string, siblings: readonly (readonly [string, number, RowFormat])[], ) { const capacity = this.core.array(primary).rows; const growth = siblings.flatMap(([name, stride, format]) => this.core.array(name).rows < capacity ? [{ name, rows: capacity, stride, format }] : [], ); if (growth.length) await this.core.createRowsBatch(growth); } addComputePass(pass: ComputePass) { if (this.#computePasses.has(pass.id)) throw new Error(`COMPUTE_PASS_EXISTS: ${pass.id}`); this.#computePasses.set(pass.id, pass); pass.attach(this); return this.updateRenderGraph(); } removeComputePass(pass: ComputePass | string) { const id = typeof pass === "string" ? pass : pass.id; const existing = this.#computePasses.get(id); existing?.attach(undefined); this.#computePasses.delete(id); return this.updateRenderGraph(); } registerMesh(mesh: MeshLike) { this.#meshes.set(mesh.id, mesh); this.referenceGeometry(mesh.geometryId); return this.updateRenderGraph(); } async unregisterMesh(mesh: MeshLike) { this.#meshes.delete(mesh.id); const geometry = this.#geometry.get(mesh.geometryId)!; geometry.references = Math.max(0, geometry.references - 1); if (geometry.references) return this.updateRenderGraph(); this.#geometry.delete(mesh.geometryId); await this.ready; await this.#inhibitRender(async () => { await this.updateRenderGraph(); await this.#allocateSpans(geometry, 0, 0); }); } createGeometry() { const id = this.#nextGeometry++; this.#geometry.set(id, { data: new Map(), pending: Promise.resolve(), references: 0, vertexRow: 0, vertexCount: 0, indexRow: 0, indexCount: 0, }); return id; } geometry(id: number): Readonly { const geometry = this.#geometry.get(id); if (!geometry) throw new Error(`UNKNOWN_GEOMETRY: ${id}`); return geometry; } referenceGeometry(id: number) { this.#geometry.get(id)!.references++; } releaseGeometry(id: number) { const geometry = this.#geometry.get(id)!; geometry.references = Math.max(0, geometry.references - 1); } async cloneGeometry(id: number) { const clone = this.createGeometry(); const copies: Record = {}; for (const [kind, data] of this.geometry(id).data) copies[kind] = data.slice() as VertexData; await this.setVertexData(clone, copies, false); return clone; } /** * Replaces the given attribute streams of a geometry, repacks its interleaved `vertices` span * and `indices` span (synthesised as `0..n` when absent), and uploads exactly those rows. */ async setVertexData( geometry: number, streams: Partial>>, updateGraph = true, ) { const record = this.#geometry.get(geometry); if (!record) throw new Error(`UNKNOWN_GEOMETRY: ${geometry}`); // Validate the whole assembled geometry before touching the record or its spans. const data = new Map(record.data); for (const [kind, source] of Object.entries(streams)) { if (!source) continue; if (kind !== "indices" && !vertexLanes[kind]) throw new RangeError(`VERTEX_DATA: ${kind}`); data.set( kind, kind === "indices" ? Uint32Array.from(source) : Float32Array.from(source), ); } const vertexCount = (data.get("positions")?.length ?? 0) / 3; for (const [kind, [, width]] of Object.entries(vertexLanes)) { const stream = data.get(kind); if (stream && stream.length !== vertexCount * width) throw new RangeError(`VERTEX_DATA: ${kind}`); } const indices = data.get("indices"); const source = streams.indices; if (indices && source) for (let index = 0; index < indices.length; index++) if (source[index] !== indices[index] || indices[index] >= vertexCount) throw new RangeError(`VERTEX_DATA: indices[${index}]`); const indexCount = indices?.length ?? vertexCount; await this.ready; if (!updateGraph) { record.data = data; await this.#allocateSpans(record, vertexCount, indexCount); await this.#packGeometry(record); return; } const pending = record.pending.then(async () => { this.markDirty(true); record.data = data; await this.#allocateSpans(record, vertexCount, indexCount); await this.#packGeometry(record); await this.updateRenderGraph(); }); record.pending = pending.catch(() => undefined); await this.#inhibitRender(() => pending); } /** * Holds render signal 4 across a structural mutation until the graph reflecting it is * published, so an older loadout switch clearing signal 6 cannot expose stale spans. */ async #inhibitRender(operation: () => Promise) { if (!this.#inhibits++) this.#signals![4] = 1; try { return await operation(); } finally { if (this.#graphBatchDepth) this.#batchInhibits++; else this.#releaseRender(); } } #releaseRender() { if (--this.#inhibits) return; this.#signals![4] = 0; this.#signals![5] = 1; } /** Packs the geometry's CPU streams into its `vertices`/`indices` spans and uploads them. */ async #packGeometry(record: Geometry) { const { vertexCount, indexCount } = record; const indices = record.data.get("indices"); if (vertexCount) { const vertices = this.core.array("vertices").view as Float32Array; const base = record.vertexRow * vertexWidth; vertices.fill(0, base, base + vertexCount * vertexWidth); for (let vertex = 0; vertex < vertexCount; vertex++) { const at = base + vertex * vertexWidth; vertices[at + 12] = 1; vertices[at + 15] = 1; vertices.fill(1, at + 16, at + 20); } for (const [kind, [offset, width]] of Object.entries(vertexLanes)) { const data = record.data.get(kind); if (!data || data.length !== vertexCount * width) continue; for (let vertex = 0; vertex < vertexCount; vertex++) for (let lane = 0; lane < width; lane++) vertices[base + vertex * vertexWidth + offset + lane] = data[vertex * width + lane]; } if (!record.data.has("uvs2")) { const uvs = record.data.get("uvs"); if (uvs) for (let vertex = 0; vertex < vertexCount; vertex++) { const at = base + vertex * vertexWidth; vertices[at + 10] = uvs[vertex * 2]; vertices[at + 11] = uvs[vertex * 2 + 1]; } } const indexView = this.core.array("indices").view as Uint32Array; const indexBase = record.indexRow * 4; if (indices) indexView.set(indices, indexBase); else for (let index = 0; index < indexCount; index++) indexView[indexBase + index] = index; await this.core.syncRows("vertices", record.vertexRow, vertexCount); await this.core.syncRows( "indices", record.indexRow, Math.ceil(indexCount / 4), ); } } /** Re-reserves the geometry's spans when their sizes change; zero counts free them. */ async #allocateSpans( geometry: Geometry, vertexCount: number, indexCount: number, ) { const indexRows = Math.ceil(indexCount / 4); const currentIndexRows = Math.ceil(geometry.indexCount / 4); if (vertexCount !== geometry.vertexCount) { if (geometry.vertexCount) await this.core.deleteObject("vertices", geometry.vertexRow); geometry.vertexRow = vertexCount ? await this.core.allocateObject("vertices", vertexCount) : 0; geometry.vertexCount = vertexCount; } if (indexRows !== currentIndexRows) { if (currentIndexRows) await this.core.deleteObject("indices", geometry.indexRow); geometry.indexRow = indexRows ? await this.core.allocateObject("indices", indexRows) : 0; } geometry.indexCount = indexCount; } geometryData(id: number, kind: string) { return this.#geometry.get(id)?.data.get(kind); } registerShader(material: ShaderLike) { this.#shaders.set(material.id, material); return this.updateRenderGraph(); } unregisterShader(id: number) { this.#shaders.delete(id); return this.updateRenderGraph(); } registerTexture(texture: Omit) { const number = this.#nextTexture++; this.#textures.set(number, { ...texture, number }); return { number, ready: this.updateRenderGraph() }; } unregisterTexture(number: number) { this.#textures.delete(number); return this.updateRenderGraph(); } setPostProcess(effect: PostProcessState, enabled: boolean) { if (enabled) this.#effects.set(effect.id, effect); else this.#effects.delete(effect.id); 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) { this.#graphBatchDirty = true; return Promise.resolve(); } const update = this.#graphUpdates.then(async () => { await this.ready; await this.#compileRenderGraph(); }); this.#graphUpdates = update.catch(() => undefined); return update; } /** Compiles a serialized render graph and returns its reusable loadout ID. */ async compileRenderGraph(serialized: string) { if (typeof serialized !== "string") throw new TypeError("Serialized render graph must be a string"); await this.ready; return this.core.compileGraph(serialized); } /** Switches this Scene to a previously compiled render-graph loadout. */ async setLoadout(id: string) { if (typeof id !== "string") throw new TypeError("Loadout ID must be a string"); await this.ready; await this.core.switchLoadout(id); } /** Returns an isolated snapshot of the Scene-authored render graph. */ async getRenderGraph() { await this.ready; if (!this.#renderGraph) throw new Error("Render graph is not ready"); return structuredClone(this.#renderGraph); } async #compileRenderGraph() { // Re-assert the bundle block now that this compile is running: an earlier switch in the // queue cleared the one set when it was enqueued, and bucket rows are mutated below. this.markDirty(true); const buffers = new Map(); const textures = new Map(); const samplers = new Map(); const computePipelines: object[] = []; const renderPipelines: object[] = []; const passes: object[] = []; const addBuffer = (value: GraphBuffer) => buffers.set(value.id, value); const addTexture = (value: GraphTexture) => textures.set(value.id, value); const addSampler = (value: GraphSampler) => samplers.set(value.id, value); for (const [id, array] of [ ["point-lights", "pointLights"], ["rect-lights", "rectAreaLights"], ["spot-lights", "spotLights"], ["directional-lights", "directionalLights"], ["ambient-lights", "ambientLights"], ["clusters", "clusters"], ]) addBuffer({ id, array, usage: ["storage"] }); addBuffer({ id: "accent", array: "sceneAccent", usage: ["uniform"] }); computePipelines.push({ id: "cluster-lights", code: clusterShader, entry: "main", }); passes.push({ id: "cluster-lights", type: "compute", pipeline: "cluster-lights", dispatch: [4, 1, 1], bindings: [ "point-lights", "rect-lights", "spot-lights", "directional-lights", "ambient-lights", "clusters", ].map((resource, binding) => ({ group: 0, binding, resource })), }); for (const pass of this.#computePasses.values()) { pass.buffers.forEach(addBuffer); pass.textures.forEach(addTexture); pass.samplers.forEach(addSampler); computePipelines.push({ id: pass.id, code: pass.code, entry: pass.entry, }); passes.push({ id: pass.id, type: "compute", pipeline: pass.id, after: pass.after.length ? pass.after : ["cluster-lights"], bindings: pass.bindings, dispatch: pass.dispatch, }); } const computeIds = [...this.#computePasses.keys()]; const renderCategory = (material: number) => { const flags = Math.round(this.array("materials").row(material)[11]); const transmitted = this.array("materialExtensions").row(material)[0] > 0; return transmitted ? 1 : (flags & 3) === 2 ? 2 : 0; }; // One draw per run of consecutive faces sharing a material, in category order: // opaque/mask first, then transmission, then blended, each in mesh ID order. type ForwardDraw = { mesh: MeshLike; geometry: Readonly; material: number; firstIndex: number; indices: number; }; const forwardDraws: ForwardDraw[] = []; for (const mesh of this.#meshes.values()) { const geometry = this.geometry(mesh.geometryId); if (!geometry.vertexCount) continue; const fallback = Number(this.array("meshInfo").row(mesh.id)[1]); if (!mesh.faceMaterials.size) { forwardDraws.push({ mesh, geometry, material: fallback, firstIndex: geometry.indexRow * 4, indices: geometry.indexCount, }); continue; } const faces = Math.floor(geometry.indexCount / 3); let start = 0; let material = mesh.faceMaterials.get(0) ?? fallback; for (let face = 1; face <= faces; face++) { const next = face < faces ? (mesh.faceMaterials.get(face) ?? fallback) : -1; if (next === material) continue; forwardDraws.push({ mesh, geometry, material, firstIndex: geometry.indexRow * 4 + start * 3, indices: (face - start) * 3, }); start = face; material = next; } } forwardDraws.sort( (left, right) => renderCategory(left.material) - renderCategory(right.material) || left.mesh.id - right.mesh.id, ); 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"; if (!directToCanvas) addTexture({ id: "hdr", format: hdrFormat, size: ["canvas", "canvas", 1], usage: ["render", "sampled"], transient: false, }); addTexture({ id: "depth", format: "depth24plus", size: ["canvas", "canvas", 1], usage: ["render"], transient: true, }); if (compositeSceneColor) addTexture({ id: "scene-color", format: hdrFormat, size: ["canvas", "canvas", 1], usage: ["render", "sampled"], transient: true, }); addSampler({ id: "linear", magFilter: "linear", minFilter: "linear" }); addSampler({ id: "material-linear", magFilter: "linear", minFilter: "linear", mipmapFilter: "linear", addressModeU: "repeat", addressModeV: "repeat", anisotropyClamp: 16, }); for (const { number: _, source: __, sampler, ...texture } of this.#textures.values()) { addTexture(texture); if (sampler) addSampler({ id: `${texture.id}-sampler`, ...sampler }); } 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", code: clearShader, vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }] }, }); passes.push({ id: "forward-empty", type: "render", pipeline: "empty-forward", after: previous, color: [{ resource: "hdr", clear: [...this.clearColor] }], draw: { vertices: 0 }, }); previous = ["forward-empty"]; } else { for (const [id, array] of [ ["node-positions", "nodePositions"], ["node-rotors", "nodeRotors"], ["node-scales", "nodeScales"], ["mesh-info", "meshInfo"], ["materials", "materials"], ["camera-matrices", "cameraMatrices"], ]) addBuffer({ id, array, usage: ["storage"] }); if (compositeSceneColor) { renderPipelines.push({ id: "scene-color-clear", code: clearShader, vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }] }, }); passes.push({ id: "scene-color-clear", type: "render", pipeline: "scene-color-clear", after: previous, color: [ { resource: "scene-color", clear: [...this.clearColor], }, ], draw: { vertices: 0 }, }); previous = ["scene-color-clear"]; } addBuffer({ id: "vertices", array: "vertices", usage: ["vertex"], sync: "manual", }); addBuffer({ id: "indices", array: "indices", usage: ["index"], sync: "manual", }); const forwardPipelines = new Map(); let firstColor = !directToCanvas; let firstDepth = !directToCanvas; let sceneColorReady = false; let forwardTarget = directToCanvas ? "canvas" : compositeSceneColor ? "scene-color" : "hdr"; const attachments = () => { const color = firstColor ? { clear: [...this.clearColor] } : { load: "load" }; const depth = firstDepth ? { clear: 1 } : { load: "load" }; firstColor = false; firstDepth = false; return { color: [{ resource: forwardTarget, ...color }], depth: { resource: "depth", ...depth }, vertexBuffers: [{ slot: 0, resource: "vertices" }], indexBuffer: { resource: "indices", format: "uint32" as const }, }; }; // Opaque and mask draws are rows of a persistent indirect bucket per pipeline + bind set, // so the graph holds one pass per bucket and membership is a row write. type Bucket = { pipeline: string; bindings: object[]; rows: number[][] }; const buckets = new Map(); const flushBuckets = () => { for (const [key, bucket] of buckets) { const id = this.#buckets.get(key) ?? this.#buckets.size; this.#buckets.set(key, id); bucketRows.set(id, bucket.rows); addBuffer({ id: `draws-${id}`, array: `draws.${id}`, usage: ["indirect"], sync: "manual", }); passes.push({ id: `forward-bucket-${id}`, type: "render", pipeline: bucket.pipeline, after: previous, bindings: bucket.bindings, ...attachments(), indirect: { resource: `draws-${id}` }, }); previous = [`forward-bucket-${id}`]; } buckets.clear(); }; for (const draw of forwardDraws) { const { mesh, geometry, material } = draw; const id = `forward-${mesh.id}-${draw.firstIndex}`; const hasNormals = geometry.data.has("normals"); const hasUvs = geometry.data.has("uvs"); const hasTangents = geometry.data.has("tangents"); if (mesh.id > 65535 || material > 65534) throw new RangeError( `Scene handle limit: mesh ${mesh.id}, material ${material}`, ); const pointers = this.array("materialTextures").row(material); const texture = (lane: number) => pointers[lane] ? this.#textures.get(Number(pointers[lane]) - 1) : undefined; const materialTextures = Array.from({ length: 13 }, (_, lane) => lane === 2 && !hasTangents ? undefined : texture(lane), ); const detailed = hasNormals && hasUvs; const flags = Math.round(this.array("materials").row(material)[11]); const extensionInfo = this.array("materialExtensions").row(material); const transmitted = extensionInfo[0] > 0; let mask = detailed ? materialTextures.reduce( (value, current, lane) => value | (current === undefined ? 0 : 1 << lane), 0, ) : 0; if (!transmitted) mask &= ~((1 << 5) | (1 << 6)); else if (extensionInfo[2] <= 0) mask &= ~(1 << 6); if (extensionInfo[8] <= 0) mask &= ~(1 << 7); if (extensionInfo[16] <= 0) mask &= ~((1 << 8) | (1 << 9)); if (extensionInfo[20] <= 0) mask &= ~(1 << 10); 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) { sceneColorReady = true; if (compositeSceneColor) { forwardTarget = "hdr"; clearCompositeLayer = true; } else { addTexture({ id: "scene-color", format: hdrFormat, size: ["canvas", "canvas", 1], usage: ["render", "sampled"], transient: true, }); renderPipelines.push({ id: "copy-scene-color", code: copyShader, vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }], }, }); passes.push({ id: "copy-scene-color", type: "render", pipeline: "copy-scene-color", after: previous, bindings: [ { group: 0, binding: 0, resource: "hdr" }, { group: 0, binding: 1, resource: "linear" }, ], color: [{ resource: "scene-color", clear: [...this.clearColor] }], draw: { vertices: 3 }, }); previous = ["copy-scene-color"]; } } const code = detailed ? pbrShader( mask, flags, transmitted, textureInfo, extensionInfo, sharedOcclusion, lightMask, directToCanvas, ) : basicForwardShader; const pipelineKey = `${flags}-${transmitted ? 1 : 0}\n${code}`; let pipeline = forwardPipelines.get(pipelineKey); if (!pipeline) { pipeline = `forward-${detailed ? "pbr" : "basic"}-${forwardPipelines.size}`; forwardPipelines.set(pipelineKey, pipeline); renderPipelines.push({ id: pipeline, code, vertex: { entry: "vertex", buffers: [ { arrayStride: 80, attributes: detailed ? vertexAttributes : vertexAttributes.slice(0, 1), }, ], }, fragment: { entry: "fragment", targets: [ { format: directToCanvas ? "canvas" : hdrFormat, ...((flags & 3) === 2 ? { blend: { color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add", }, alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add", }, }, } : {}), }, ], }, depthStencil: { format: "depth24plus", depth_write_enabled: (flags & 3) !== 2 && !transmitted, depth_compare: "less", }, primitive: { topology: "triangle-list", frontFace: "ccw", cullMode: (flags & 4) !== 0 ? null : "back", conservative: false, }, }); } const instance = (mesh.id + (material + 1) * 65536) >>> 0; const samplerTexture = materialTextures.find( (value, lane) => value !== undefined && (mask & (1 << lane)) !== 0, ); const bindings = [ ...[ "clusters", "accent", "node-positions", "node-rotors", "node-scales", "mesh-info", "materials", "camera-matrices", ].map((resource, binding) => ({ group: 0, binding, resource, })), ...(detailed ? [ { group: 0, binding: 8, resource: "point-lights" }, { group: 0, binding: 9, resource: "spot-lights" }, { group: 0, binding: 10, resource: "directional-lights", }, ].filter((_, index) => lightMask & (1 << index)) : []), ...(detailed && mask ? [ { group: 1, binding: 0, resource: samplerTexture?.sampler ? `${samplerTexture.id}-sampler` : "material-linear", }, ...materialTextures.flatMap((value, lane) => value && (mask & (1 << lane)) !== 0 && !(lane === 4 && sharedOcclusion) ? [ { group: 1, binding: lane + 1, resource: value.id, }, ] : [], ), ] : []), ...(detailed && transmitted ? [ { group: 2, binding: 0, resource: "scene-color" }, { group: 2, binding: 1, resource: "linear" }, ] : []), ]; if (!transmitted && (flags & 3) !== 2) { const key = `${pipelineKey}\n${JSON.stringify(bindings)}`; const bucket = buckets.get(key) ?? buckets.set(key, { pipeline, bindings, rows: [] }).get(key)!; bucket.rows.push([ draw.indices, 1, draw.firstIndex, geometry.vertexRow, instance, mesh.id, material, 0, ]); continue; } const target = attachments(); if (clearCompositeLayer) target.color[0] = { resource: forwardTarget, clear: [0, 0, 0, 0] }; passes.push({ id, type: "render", pipeline, after: previous, bindings, ...target, draw: { indices: draw.indices, firstIndex: draw.firstIndex, baseVertex: geometry.vertexRow, instances: 1, firstInstance: instance, }, }); previous = [id]; } flushBuckets(); } for (const material of this.#shaders.values()) { const id = `shader-${material.id}`; renderPipelines.push({ id, code: material.code, vertex: { entry: material.vertexEntry }, fragment: { entry: material.fragmentEntry, targets: [{ format: hdrFormat }], }, }); passes.push({ id, type: "render", pipeline: id, after: previous, color: [{ resource: "hdr", load: "load" }], draw: { vertices: 3 }, }); previous = [id]; } let input = "hdr"; for (const [index, effect] of [...this.#effects.values()].entries()) { const output = `post-${index}`; const pipeline = `post-${effect.id}`; addTexture({ id: output, format: hdrFormat, size: ["canvas", "canvas", 1], usage: ["render", "sampled"], transient: true, }); renderPipelines.push({ id: pipeline, code: effectFragment(effect.kind, effect.options), vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }] }, }); passes.push({ id: pipeline, type: "render", pipeline, after: previous, bindings: [ { group: 0, binding: 0, resource: input }, { group: 0, binding: 1, resource: "linear" }, ], color: [{ resource: output, clear: [0, 0, 0, 1] }], draw: { vertices: 3 }, }); input = output; previous = [pipeline]; } 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", resources: { buffers: [...buffers.values()], textures: [...textures.values()], samplers: [...samplers.values()].map(({ id, ...descriptor }) => ({ id, descriptor, })), }, pipelines: { render: renderPipelines, compute: computePipelines }, passes, }; this.#renderGraph = structuredClone(graph); await this.#writeBuckets(bucketRows); const id = await this.core.compileGraph(serialize(graph)); await this.core.switchLoadout(id); } /** * Sizes each `draws.` bucket to hold its commands (geometric growth), writes them as * indexed indirect rows, zeroes the inactive remainder, and uploads the whole bucket. */ async #writeBuckets(bucketRows: Map) { const growth = [...bucketRows].flatMap(([id, commands]) => { const current = this.#bucketCapacity[id] ?? 0; return commands.length > current ? [{ id, rows: Math.max(commands.length, current * 2) }] : []; }); if (growth.length) { await this.core.createRowsBatch( growth.map(({ id, rows }) => ({ name: `draws.${id}`, rows, stride: 32, format: "u32", })), ); for (const { id, rows } of growth) this.#bucketCapacity[id] = rows; } for (const [id, commands] of bucketRows) { const array = this.core.array(`draws.${id}`); const view = array.view as Uint32Array; view.fill(0, commands.length * 8); commands.forEach((command, row) => view.set(command, row * 8)); await this.core.syncRows(`draws.${id}`, 0, array.rows); } } dispose() { this.core.dispose(); } }