Remove vsync and dirty-flag frame gating, optimize PBR sampling and lighting, add a direct-to-canvas loadout, and preserve camera state on viewer resize. Add regression coverage and update rendering documentation. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b382-1e79-712b-abd1-d29201c9bb5e Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
2000 lines
72 KiB
TypeScript
2000 lines
72 KiB
TypeScript
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<number, number>;
|
|
};
|
|
type VertexData = Float32Array | Uint32Array;
|
|
/** One geometry: a span of interleaved `vertices` rows plus a span of `indices` rows. */
|
|
type Geometry = {
|
|
data: Map<string, VertexData>;
|
|
/** Serialises published mutations of this geometry's spans. */
|
|
pending: Promise<unknown>;
|
|
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<string, [offset: number, width: number]> = {
|
|
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<string, unknown>;
|
|
};
|
|
type TextureState = GraphTexture & {
|
|
number: number;
|
|
source?: string | ImageBitmap;
|
|
sampler?: Omit<GraphSampler, "id">;
|
|
};
|
|
|
|
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<storage, read> pointLights: array<vec4<f32>>;
|
|
@group(0) @binding(1) var<storage, read> rectLights: array<vec4<f32>>;
|
|
@group(0) @binding(2) var<storage, read> spotLights: array<vec4<f32>>;
|
|
@group(0) @binding(3) var<storage, read> directionalLights: array<vec4<f32>>;
|
|
@group(0) @binding(4) var<storage, read> ambientLights: array<vec4<f32>>;
|
|
@group(0) @binding(5) var<storage, read_write> clusters: array<u32>;
|
|
|
|
@compute @workgroup_size(64)
|
|
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
|
if (id.x != 0u) { return; }
|
|
var count = 0u;
|
|
var pointCount = 0u;
|
|
var spotCount = 0u;
|
|
var light = vec3<f32>(0.0);
|
|
var ambientLight = vec3<f32>(0.0);
|
|
var pointLight = vec3<f32>(0.0);
|
|
var spotLight = vec3<f32>(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<u32>(light.r);
|
|
clusters[2] = bitcast<u32>(light.g);
|
|
clusters[3] = bitcast<u32>(light.b);
|
|
clusters[4] = bitcast<u32>(pointLight.r);
|
|
clusters[5] = bitcast<u32>(pointLight.g);
|
|
clusters[6] = bitcast<u32>(pointLight.b);
|
|
clusters[7] = bitcast<u32>(spotLight.r);
|
|
clusters[8] = bitcast<u32>(spotLight.g);
|
|
clusters[9] = bitcast<u32>(spotLight.b);
|
|
clusters[10] = pointCount;
|
|
clusters[${compactSpotCount}] = spotCount;
|
|
clusters[${compactAmbientStart}] = bitcast<u32>(ambientLight.r);
|
|
clusters[${compactAmbientStart + 1}] = bitcast<u32>(ambientLight.g);
|
|
clusters[${compactAmbientStart + 2}] = bitcast<u32>(ambientLight.b);
|
|
}`;
|
|
|
|
const basicForwardShader = /* wgsl */ `
|
|
struct Accent { color: vec4<f32> }
|
|
struct VertexOutput {
|
|
@invariant @builtin(position) position: vec4<f32>,
|
|
@location(0) normal: vec3<f32>,
|
|
@location(1) @interpolate(flat) mesh: u32,
|
|
@location(2) @interpolate(flat) material: u32,
|
|
}
|
|
|
|
@group(0) @binding(0) var<storage, read> clusters: array<u32>;
|
|
@group(0) @binding(1) var<uniform> accent: Accent;
|
|
@group(0) @binding(2) var<storage, read> positions: array<vec4<f32>>;
|
|
@group(0) @binding(3) var<storage, read> rotors: array<vec4<f32>>;
|
|
@group(0) @binding(4) var<storage, read> scales: array<vec4<f32>>;
|
|
@group(0) @binding(5) var<storage, read> meshInfo: array<u32>;
|
|
@group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>;
|
|
@group(0) @binding(7) var<storage, read> cameraMatrices: array<vec4<f32>>;
|
|
|
|
fn rotate(rotor: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
|
|
return value + 2.0 * cross(rotor.xyz, cross(rotor.xyz, value) + rotor.w * value);
|
|
}
|
|
|
|
@vertex
|
|
fn vertex(@location(0) point: vec3<f32>, @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<f32>(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<f32>(2.0, 2.0, 2.0, 1.0), clip, visible != 0u);
|
|
output.normal = normalize(rotate(rotors[instance], vec3<f32>(0.0, 0.0, 1.0)));
|
|
output.mesh = instance;
|
|
output.material = packed >> 16u;
|
|
return output;
|
|
}
|
|
|
|
@fragment
|
|
fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
|
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<f32>(bitcast<f32>(clusters[1]), bitcast<f32>(clusters[2]), bitcast<f32>(clusters[3]));
|
|
let color = base.rgb * clusterLight * accent.color.rgb * mix(1.0, 1.1, properties.x);
|
|
return vec4<f32>(color, base.a);
|
|
}`;
|
|
|
|
function pbrShader(
|
|
mask: number,
|
|
flags: number,
|
|
transmission: boolean,
|
|
textureInfo: ArrayLike<number>,
|
|
extensionInfo: ArrayLike<number>,
|
|
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<f32>,
|
|
@location(0) world: vec3<f32>,
|
|
@location(1) normal: vec3<f32>,
|
|
@location(2) uv: vec2<f32>,
|
|
@location(3) tangent: vec4<f32>,
|
|
@location(4) color: vec4<f32>,
|
|
@location(5) uv2: vec2<f32>,
|
|
@location(6) @interpolate(flat) material: u32,
|
|
}
|
|
|
|
@group(0) @binding(0) var<storage, read> clusters: array<u32>;
|
|
@group(0) @binding(1) var<uniform> accent: vec4<f32>;
|
|
@group(0) @binding(2) var<storage, read> positions: array<vec4<f32>>;
|
|
@group(0) @binding(3) var<storage, read> rotors: array<vec4<f32>>;
|
|
@group(0) @binding(4) var<storage, read> scales: array<vec4<f32>>;
|
|
@group(0) @binding(5) var<storage, read> meshInfo: array<u32>;
|
|
@group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>;
|
|
@group(0) @binding(7) var<storage, read> cameraMatrices: array<vec4<f32>>;
|
|
${lightMask & 1 ? "@group(0) @binding(8) var<storage, read> pointLights: array<vec4<f32>>;" : ""}
|
|
${lightMask & 2 ? "@group(0) @binding(9) var<storage, read> spotLights: array<vec4<f32>>;" : ""}
|
|
${lightMask & 4 ? "@group(0) @binding(10) var<storage, read> directionalLights: array<vec4<f32>>;" : ""}
|
|
${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<f32>;` : "")).join("\n")}
|
|
${transmission ? "@group(2) @binding(0) var sceneColor: texture_2d<f32>;\n@group(2) @binding(1) var sceneSampler: sampler;" : ""}
|
|
|
|
fn rotate(rotor: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
|
|
return value + 2.0 * cross(rotor.xyz, cross(rotor.xyz, value) + rotor.w * value);
|
|
}
|
|
|
|
@vertex
|
|
fn vertex(
|
|
@location(0) point: vec3<f32>,
|
|
@location(1) localNormal: vec3<f32>,
|
|
@location(2) uv: vec2<f32>,
|
|
@location(3) localTangent: vec4<f32>,
|
|
@location(4) color: vec4<f32>,
|
|
@location(5) uv2: vec2<f32>,
|
|
@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<f32>(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<f32>(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<f32>, view: vec3<f32>, lightDirection: vec3<f32>, radiance: vec3<f32>, diffuseColor: vec3<f32>, nDotV: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
|
|
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<f32>, view: vec3<f32>, world: vec3<f32>, diffuseColor: vec3<f32>, nDotV: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
|
|
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<f32>, view: vec3<f32>, world: vec3<f32>, diffuseColor: vec3<f32>, nDotV: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
|
|
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<f32>(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<f32>, view: vec3<f32>, diffuseColor: vec3<f32>, nDotV: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
|
|
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<f32>(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<f32> {
|
|
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<f32>(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<f32>(bitcast<f32>(clusters[${compactAmbientStart}]), bitcast<f32>(clusters[${compactAmbientStart + 1}]), bitcast<f32>(clusters[${compactAmbientStart + 2}]));
|
|
var directLight = vec3<f32>(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<f32>(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<f32> {
|
|
return vec4(0.0);
|
|
}
|
|
@fragment fn fragment() -> @location(0) vec4<f32> {
|
|
return vec4(0.0);
|
|
}`;
|
|
|
|
const fullscreenVertex = /* wgsl */ `
|
|
struct VertexOutput {
|
|
@builtin(position) position: vec4<f32>,
|
|
@location(0) uv: vec2<f32>,
|
|
}
|
|
@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<f32>;
|
|
@group(0) @binding(1) var sourceSampler: sampler;
|
|
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
|
return textureSample(source, sourceSampler, input.uv);
|
|
}`;
|
|
|
|
function effectFragment(kind: string, options: Record<string, unknown>) {
|
|
const amount = Number(options.amount ?? options.exposure ?? 1);
|
|
const safeAmount = Number.isFinite(amount) ? amount : 1;
|
|
const body: Record<string, string> = {
|
|
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<f32>(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<f32>(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<f32>(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<f32>;
|
|
@group(0) @binding(1) var sourceSampler: sampler;
|
|
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
|
${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<f32>) -> vec4<f32> {
|
|
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<f32>;
|
|
@group(0) @binding(1) var sourceSampler: sampler;
|
|
${composite === "scene" ? "@group(0) @binding(2) var scene: texture_2d<f32>;" : ""}
|
|
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
|
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<string, unknown>)[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<this>;
|
|
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<string, ComputePass>();
|
|
#meshes = new Map<number, MeshLike>();
|
|
#shaders = new Map<number, ShaderLike>();
|
|
#effects = new Map<string, PostProcessState>();
|
|
#textures = new Map<number, TextureState>();
|
|
#geometry = new Map<number, Geometry>();
|
|
#nextGeometry = 1;
|
|
#nextTexture = 0;
|
|
#graphBatchDepth = 0;
|
|
#graphBatchDirty = false;
|
|
#inhibits = 0;
|
|
#batchInhibits = 0;
|
|
/** Indirect draw buckets: pipeline + bind-set key → `draws.<id>`, and each bucket's row capacity. */
|
|
#buckets = new Map<string, number>();
|
|
#bucketCapacity: number[] = [];
|
|
#writeBatchDepth = 0;
|
|
#writeBatchDirty = false;
|
|
#writeBatchBundleDirty = false;
|
|
#signals?: Float32Array;
|
|
#arrays = new WeakMap<object, object>();
|
|
#views = new WeakMap<object, object>();
|
|
#lights = {
|
|
point: new Set<number>(),
|
|
spot: new Set<number>(),
|
|
directional: new Set<number>(),
|
|
};
|
|
|
|
constructor(
|
|
canvas: HTMLCanvasElement,
|
|
options: {
|
|
arenaBytes?: number;
|
|
debug?: boolean;
|
|
fps?: number;
|
|
hdr?: boolean;
|
|
directToCanvas?: boolean;
|
|
clearColor?: ArrayLike<number>;
|
|
} = {},
|
|
) {
|
|
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<number>) => {
|
|
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<T extends Float32Array | Uint32Array | Int32Array>(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<T>(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<T>(operation: () => T | Promise<T>) {
|
|
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<Geometry> {
|
|
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<string, VertexData> = {};
|
|
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<Record<string, ArrayLike<number>>>,
|
|
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<T>(operation: () => Promise<T>) {
|
|
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<TextureState, "number">) {
|
|
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<string, GraphBuffer>();
|
|
const textures = new Map<string, GraphTexture>();
|
|
const samplers = new Map<string, GraphSampler>();
|
|
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<Geometry>;
|
|
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<number, number[][]>();
|
|
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<f32> { return canvasOutput(vec4<f32>(${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<string, string>();
|
|
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<string, Bucket>();
|
|
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.<id>` 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<number, number[][]>) {
|
|
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();
|
|
}
|
|
}
|