From 9768765d74e9b45f1021561da9a3f49e8c27f5c7 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 20 Aug 2026 06:39:30 +0000 Subject: [PATCH] Replace addons with conventional handles Provide the single-loadout Scene API, SAB-backed meshes, cameras, materials, lights, workers, post effects, and tutorial playgrounds. Batch matching row growth so active GPU loadouts refresh once. Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae Co-authored-by: Heaust Azure --- addons/default-pipelines/package.json | 7 - addons/default-pipelines/src/index.js | 50 -- addons/gltf-import/package.json | 7 - addons/gltf-import/src/gltf.js | 392 ------------ addons/gltf-import/src/index.js | 62 -- addons/gltf-import/src/worker.js | 33 - addons/handles/package.json | 10 + addons/handles/src/ComputePass.ts | 92 +++ addons/handles/src/Mesh.ts | 132 ++++ addons/handles/src/Node.ts | 69 +++ .../handles/src/PostProcesses/ColorGrading.ts | 9 + .../src/PostProcesses/DynamicExposure.ts | 6 + addons/handles/src/PostProcesses/Edges.ts | 6 + addons/handles/src/PostProcesses/FXAA.ts | 6 + .../handles/src/PostProcesses/PostProcess.ts | 37 ++ addons/handles/src/PostProcesses/SSAO.ts | 6 + .../handles/src/PostProcesses/Silhouette.ts | 6 + addons/handles/src/Scene.ts | 586 ++++++++++++++++++ addons/handles/src/bvh/picking.ts | 56 ++ addons/handles/src/bvh/worker.ts | 97 +++ addons/handles/src/camera/ArcRotateCamera.ts | 148 +++++ addons/handles/src/camera/Camera.ts | 96 +++ addons/handles/src/camera/FollowCamera.ts | 82 +++ addons/handles/src/camera/FreeCamera.ts | 101 +++ addons/handles/src/importers/gltf.ts | 56 ++ addons/handles/src/importers/worker.ts | 128 ++++ addons/handles/src/index.ts | 29 + addons/handles/src/lights/AmbientLight.ts | 26 + addons/handles/src/lights/DirectionalLight.ts | 26 + addons/handles/src/lights/PointLight.ts | 30 + addons/handles/src/lights/RectAreaLight.ts | 39 ++ addons/handles/src/lights/SpotLight.ts | 37 ++ addons/handles/src/materials/PBRMaterial.ts | 77 +++ .../handles/src/materials/ShaderMaterial.ts | 48 ++ addons/handles/src/materials/Texture.ts | 44 ++ addons/mesh-handles/package.json | 7 - addons/mesh-handles/src/index.js | 55 -- addons/render-graph-ast/package.json | 7 - addons/render-graph-ast/src/index.js | 34 - addons/render-graph-fxnode/package.json | 10 - addons/render-graph-fxnode/src/index.js | 18 - addons/render-graph-js/package.json | 10 - addons/render-graph-js/src/index.js | 12 - core/index.js | 12 + core/lib.rs | 39 ++ core/render_graph/store.rs | 9 +- core/worker.js | 3 + docs/.vitepress/Playground.vue | 74 ++- docs/.vitepress/config.js | 22 +- docs/guide/cameras.md | 75 +++ docs/guide/compute.md | 42 ++ docs/guide/core.md | 44 ++ docs/guide/getting-started.md | 57 ++ docs/guide/importing-and-picking.md | 35 ++ docs/guide/lights.md | 47 ++ docs/guide/materials.md | 59 ++ docs/guide/meshes-and-instances.md | 49 ++ docs/guide/post-processing.md | 36 ++ docs/guide/scene-and-sab.md | 44 ++ docs/index.md | 56 +- docs/playground.md | 2 +- package-lock.json | 53 +- 62 files changed, 2749 insertions(+), 798 deletions(-) delete mode 100644 addons/default-pipelines/package.json delete mode 100644 addons/default-pipelines/src/index.js delete mode 100644 addons/gltf-import/package.json delete mode 100644 addons/gltf-import/src/gltf.js delete mode 100644 addons/gltf-import/src/index.js delete mode 100644 addons/gltf-import/src/worker.js create mode 100644 addons/handles/package.json create mode 100644 addons/handles/src/ComputePass.ts create mode 100644 addons/handles/src/Mesh.ts create mode 100644 addons/handles/src/Node.ts create mode 100644 addons/handles/src/PostProcesses/ColorGrading.ts create mode 100644 addons/handles/src/PostProcesses/DynamicExposure.ts create mode 100644 addons/handles/src/PostProcesses/Edges.ts create mode 100644 addons/handles/src/PostProcesses/FXAA.ts create mode 100644 addons/handles/src/PostProcesses/PostProcess.ts create mode 100644 addons/handles/src/PostProcesses/SSAO.ts create mode 100644 addons/handles/src/PostProcesses/Silhouette.ts create mode 100644 addons/handles/src/Scene.ts create mode 100644 addons/handles/src/bvh/picking.ts create mode 100644 addons/handles/src/bvh/worker.ts create mode 100644 addons/handles/src/camera/ArcRotateCamera.ts create mode 100644 addons/handles/src/camera/Camera.ts create mode 100644 addons/handles/src/camera/FollowCamera.ts create mode 100644 addons/handles/src/camera/FreeCamera.ts create mode 100644 addons/handles/src/importers/gltf.ts create mode 100644 addons/handles/src/importers/worker.ts create mode 100644 addons/handles/src/index.ts create mode 100644 addons/handles/src/lights/AmbientLight.ts create mode 100644 addons/handles/src/lights/DirectionalLight.ts create mode 100644 addons/handles/src/lights/PointLight.ts create mode 100644 addons/handles/src/lights/RectAreaLight.ts create mode 100644 addons/handles/src/lights/SpotLight.ts create mode 100644 addons/handles/src/materials/PBRMaterial.ts create mode 100644 addons/handles/src/materials/ShaderMaterial.ts create mode 100644 addons/handles/src/materials/Texture.ts delete mode 100644 addons/mesh-handles/package.json delete mode 100644 addons/mesh-handles/src/index.js delete mode 100644 addons/render-graph-ast/package.json delete mode 100644 addons/render-graph-ast/src/index.js delete mode 100644 addons/render-graph-fxnode/package.json delete mode 100644 addons/render-graph-fxnode/src/index.js delete mode 100644 addons/render-graph-js/package.json delete mode 100644 addons/render-graph-js/src/index.js create mode 100644 docs/guide/cameras.md create mode 100644 docs/guide/compute.md create mode 100644 docs/guide/core.md create mode 100644 docs/guide/getting-started.md create mode 100644 docs/guide/importing-and-picking.md create mode 100644 docs/guide/lights.md create mode 100644 docs/guide/materials.md create mode 100644 docs/guide/meshes-and-instances.md create mode 100644 docs/guide/post-processing.md create mode 100644 docs/guide/scene-and-sab.md diff --git a/addons/default-pipelines/package.json b/addons/default-pipelines/package.json deleted file mode 100644 index c115260..0000000 --- a/addons/default-pipelines/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@yawn/default-pipelines", - "version": "0.1.0", - "description": "Optional graph-authored scene, frame, and compute pipelines for Yawn", - "type": "module", - "exports": "./src/index.js" -} diff --git a/addons/default-pipelines/src/index.js b/addons/default-pipelines/src/index.js deleted file mode 100644 index 5dc0039..0000000 --- a/addons/default-pipelines/src/index.js +++ /dev/null @@ -1,50 +0,0 @@ -const triangleShader = /* wgsl */ ` -struct Tint { color: vec4 } -@group(0) @binding(0) var tint: Tint; - -struct Vertex { @builtin(position) position: vec4 } - -@vertex fn vertex(@builtin(vertex_index) index: u32) -> Vertex { - let positions = array(vec2(-0.75, -0.65), vec2(0.75, -0.65), vec2(0.0, 0.75)); - var output: Vertex; - output.position = vec4(positions[index], 0.0, 1.0); - return output; -} - -@fragment fn fragment() -> @location(0) vec4 { return tint.color; } -`; - -const noopComputeShader = /* wgsl */ ` -@compute @workgroup_size(1) fn main() {} -`; - -/** A complete external graph used by the minimal playground; core contains neither program. */ -export function triangleGraph(colorArray = "triangle.color") { - return { - id: "triangle", - resources: { - buffers: [{ id: "color", array: colorArray, usage: ["uniform"] }], - }, - pipelines: { - compute: [{ id: "prepare", code: noopComputeShader }], - render: [{ - id: "triangle", - code: triangleShader, - vertex: { entry: "vertex" }, - fragment: { entry: "fragment", targets: [{ format: "canvas" }] }, - }], - }, - passes: [ - { id: "prepare", type: "compute", pipeline: "prepare", dispatch: [1, 1, 1] }, - { - id: "draw", - type: "render", - pipeline: "triangle", - after: ["prepare"], - bindings: [{ group: 0, binding: 0, resource: "color" }], - color: [{ resource: "canvas", clear: [0.025, 0.035, 0.055, 1] }], - draw: { vertices: 3 }, - }, - ], - }; -} diff --git a/addons/gltf-import/package.json b/addons/gltf-import/package.json deleted file mode 100644 index 1471b09..0000000 --- a/addons/gltf-import/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@yawn/gltf-import", - "version": "0.1.0", - "description": "glTF worker that publishes generic render data directly into Yawn shared SOA memory", - "type": "module", - "exports": "./src/index.js" -} diff --git a/addons/gltf-import/src/gltf.js b/addons/gltf-import/src/gltf.js deleted file mode 100644 index c3f5221..0000000 --- a/addons/gltf-import/src/gltf.js +++ /dev/null @@ -1,392 +0,0 @@ -const GLB_MAGIC = 0x46546c67; -const JSON_CHUNK = 0x4e4f534a; -const BIN_CHUNK = 0x004e4942; -const PACKET_MAGIC = 0x50445259; -const PACKET_VERSION = 1; -const COMPONENT_WIDTH = Object.freeze({ SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 }); -const COMPONENT_SIZE = Object.freeze({ 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }); -const IDENTITY = Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); -const decoder = new TextDecoder(); -const encoder = new TextEncoder(); - -const fail = code => { throw new Error(code); }; -const align4 = value => (value + 3) & ~3; -const finite = values => values.every(Number.isFinite); - -function parseContainer(source) { - if (!(source instanceof Uint8Array) || !source.byteLength) fail("GLTF_EMPTY"); - const view = new DataView(source.buffer, source.byteOffset, source.byteLength); - if (source.byteLength >= 12 && view.getUint32(0, true) === GLB_MAGIC) { - if (view.getUint32(4, true) !== 2 || view.getUint32(8, true) !== source.byteLength) - fail("GLTF_INVALID_CONTAINER"); - let offset = 12, json, binary; - while (offset < source.byteLength) { - if (offset + 8 > source.byteLength) fail("GLTF_INVALID_CONTAINER"); - const length = view.getUint32(offset, true); - const type = view.getUint32(offset + 4, true); - const end = offset + 8 + length; - if (end > source.byteLength) fail("GLTF_INVALID_CONTAINER"); - const chunk = source.subarray(offset + 8, end); - if (type === JSON_CHUNK && !json) json = chunk; - if (type === BIN_CHUNK && !binary) binary = chunk; - offset = end; - } - if (!json) fail("GLTF_JSON_MISSING"); - return { document: JSON.parse(decoder.decode(json).replace(/\0+$/u, "").trimEnd()), binary }; - } - return { document: JSON.parse(decoder.decode(source).replace(/^\uFEFF/u, "")), binary: undefined }; -} - -async function fetchBytes(uri, baseUrl, fetcher) { - const response = await fetcher(new URL(uri, baseUrl)); - if (!response.ok) fail(`HTTP_${response.status}`); - return new Uint8Array(await response.arrayBuffer()); -} - -async function loadBuffers(document, binary, baseUrl, fetcher) { - return Promise.all((document.buffers ?? []).map(async (buffer, index) => { - const bytes = buffer.uri === undefined - ? (index === 0 ? binary : undefined) - : await fetchBytes(buffer.uri, baseUrl, fetcher); - if (!bytes || bytes.byteLength < buffer.byteLength) fail("GLTF_BUFFER_INVALID"); - return bytes; - })); -} - -function component(data, offset, type) { - switch (type) { - case 5120: return data.getInt8(offset); - case 5121: return data.getUint8(offset); - case 5122: return data.getInt16(offset, true); - case 5123: return data.getUint16(offset, true); - case 5125: return data.getUint32(offset, true); - case 5126: return data.getFloat32(offset, true); - default: fail("GLTF_ACCESSOR_COMPONENT"); - } -} - -function normalizeComponent(value, type) { - switch (type) { - case 5120: return Math.max(value / 127, -1); - case 5121: return value / 255; - case 5122: return Math.max(value / 32767, -1); - case 5123: return value / 65535; - case 5125: return value / 4294967295; - default: return value; - } -} - -function viewBytes(document, buffers, index) { - const view = document.bufferViews?.[index]; - const buffer = view && buffers[view.buffer]; - if (!view || !buffer) fail("GLTF_BUFFER_VIEW_INVALID"); - const start = view.byteOffset ?? 0; - const end = start + view.byteLength; - if (end > buffer.byteLength) fail("GLTF_BUFFER_VIEW_INVALID"); - return { view, bytes: buffer.subarray(start, end) }; -} - -function readAccessor(document, buffers, index, { integer = false } = {}) { - const accessor = document.accessors?.[index]; - const width = accessor && COMPONENT_WIDTH[accessor.type]; - const size = accessor && COMPONENT_SIZE[accessor.componentType]; - if (!accessor || !width || !size || !Number.isInteger(accessor.count) || accessor.count < 0) - fail("GLTF_ACCESSOR_INVALID"); - const values = integer ? new Uint32Array(accessor.count * width) : new Float32Array(accessor.count * width); - if (accessor.bufferView !== undefined) { - const { view, bytes } = viewBytes(document, buffers, accessor.bufferView); - const stride = view.byteStride ?? width * size; - const start = accessor.byteOffset ?? 0; - if (stride < width * size || start + Math.max(0, accessor.count - 1) * stride + width * size > bytes.byteLength) - fail("GLTF_ACCESSOR_RANGE"); - const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - for (let item = 0; item < accessor.count; item++) { - for (let lane = 0; lane < width; lane++) { - let value = component(data, start + item * stride + lane * size, accessor.componentType); - if (!integer && accessor.normalized) value = normalizeComponent(value, accessor.componentType); - values[item * width + lane] = value; - } - } - } - if (accessor.sparse) { - const sparse = accessor.sparse; - const indices = sparse.indices; - const indexSize = COMPONENT_SIZE[indices.componentType]; - if (!indexSize || ![5121, 5123, 5125].includes(indices.componentType)) fail("GLTF_SPARSE_INVALID"); - const indexView = viewBytes(document, buffers, indices.bufferView).bytes; - const valueView = viewBytes(document, buffers, sparse.values.bufferView).bytes; - const indexStart = indices.byteOffset ?? 0; - const valueStart = sparse.values.byteOffset ?? 0; - if (indexStart + sparse.count * indexSize > indexView.byteLength || valueStart + sparse.count * width * size > valueView.byteLength) - fail("GLTF_SPARSE_INVALID"); - const indexData = new DataView(indexView.buffer, indexView.byteOffset, indexView.byteLength); - const valueData = new DataView(valueView.buffer, valueView.byteOffset, valueView.byteLength); - for (let item = 0; item < sparse.count; item++) { - const target = component(indexData, indexStart + item * indexSize, indices.componentType); - if (target >= accessor.count) fail("GLTF_SPARSE_INVALID"); - for (let lane = 0; lane < width; lane++) { - let value = component(valueData, valueStart + (item * width + lane) * size, accessor.componentType); - if (!integer && accessor.normalized) value = normalizeComponent(value, accessor.componentType); - values[target * width + lane] = value; - } - } - } - if (!finite(values)) fail("GLTF_ACCESSOR_NONFINITE"); - return { count: accessor.count, width, values }; -} - -const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; -const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; -const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; -function normalize(value, fallback) { - const length = Math.hypot(...value); - return length > Number.EPSILON && Number.isFinite(length) ? value.map(item => item / length) : fallback; -} -function lanes(values, index, width) { return Array.from(values.subarray(index * width, (index + 1) * width)); } - -function repairGeometry(positions, normals, tangents, uvs, indices) { - const count = positions.length / 3; - if (indices.length % 3 || Array.from(indices).some(index => index >= count)) fail("GLTF_TRIANGLES_INVALID"); - const normalsValid = normals?.length === positions.length; - const tangentsValid = tangents?.length === count * 4; - if (normalsValid && tangentsValid) - return { positions, normals, tangents, uvs, indices }; - const outPositions = [], outNormals = [], outTangents = [], outUvs = []; - for (let triangle = 0; triangle < indices.length; triangle += 3) { - const ids = [indices[triangle], indices[triangle + 1], indices[triangle + 2]]; - const p = ids.map(index => lanes(positions, index, 3)); - const uv = ids.map(index => lanes(uvs, index, 2)); - const faceNormal = normalize(cross(sub(p[1], p[0]), sub(p[2], p[0])), [0, 1, 0]); - const duv1 = sub([...uv[1], 0], [...uv[0], 0]); - const duv2 = sub([...uv[2], 0], [...uv[0], 0]); - const determinant = duv1[0] * duv2[1] - duv1[1] * duv2[0]; - const edge1 = sub(p[1], p[0]), edge2 = sub(p[2], p[0]); - const rawTangent = Math.abs(determinant) > Number.EPSILON - ? edge1.map((value, lane) => (value * duv2[1] - edge2[lane] * duv1[1]) / determinant) - : [0, 0, 0]; - const rawBitangent = Math.abs(determinant) > Number.EPSILON - ? edge2.map((value, lane) => (value * duv1[0] - edge1[lane] * duv2[0]) / determinant) - : [0, 0, 0]; - for (let corner = 0; corner < 3; corner++) { - const normal = normalize(normalsValid ? lanes(normals, ids[corner], 3) : faceNormal, faceNormal); - const projected = rawTangent.map((value, lane) => value - normal[lane] * dot(normal, rawTangent)); - const axis = Math.abs(normal[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]; - const tangent = normalize(projected, normalize(cross(axis, normal), [0, 0, 1])); - const generated = [...tangent, dot(cross(normal, tangent), rawBitangent) < 0 ? -1 : 1]; - outPositions.push(...p[corner]); - outNormals.push(...normal); - outTangents.push(...(tangentsValid ? lanes(tangents, ids[corner], 4) : generated)); - outUvs.push(...uv[corner]); - } - } - const repairedIndices = Uint32Array.from({ length: outPositions.length / 3 }, (_, index) => index); - return { - positions: new Float32Array(outPositions), - normals: new Float32Array(outNormals), - tangents: new Float32Array(outTangents), - uvs: new Float32Array(outUvs), - indices: repairedIndices, - }; -} - -function multiply(a, b) { - const result = Array(16).fill(0); - for (let column = 0; column < 4; column++) - for (let row = 0; row < 4; row++) - for (let lane = 0; lane < 4; lane++) - result[column * 4 + row] += a[lane * 4 + row] * b[column * 4 + lane]; - return result; -} - -function nodeMatrix(node) { - if (node.matrix) { - if (node.matrix.length !== 16 || !finite(node.matrix)) fail("GLTF_NODE_TRANSFORM"); - return Array.from(node.matrix); - } - const [x, y, z, w] = node.rotation ?? [0, 0, 0, 1]; - const [sx, sy, sz] = node.scale ?? [1, 1, 1]; - const [tx, ty, tz] = node.translation ?? [0, 0, 0]; - const matrix = [ - (1 - 2 * y * y - 2 * z * z) * sx, (2 * x * y + 2 * z * w) * sx, (2 * x * z - 2 * y * w) * sx, 0, - (2 * x * y - 2 * z * w) * sy, (1 - 2 * x * x - 2 * z * z) * sy, (2 * y * z + 2 * x * w) * sy, 0, - (2 * x * z + 2 * y * w) * sz, (2 * y * z - 2 * x * w) * sz, (1 - 2 * x * x - 2 * y * y) * sz, 0, - tx, ty, tz, 1, - ]; - if (!finite(matrix)) fail("GLTF_NODE_TRANSFORM"); - return matrix; -} - -function textureReference(reference) { - return reference ? { texture: reference.index, texCoord: reference.texCoord ?? 0 } : null; -} - -function materialMetadata(material, index) { - const pbr = material.pbrMetallicRoughness ?? {}; - const ior = material.extensions?.KHR_materials_ior?.ior ?? 1.5; - if (!Number.isFinite(ior) || (ior !== 0 && ior < 1)) fail("GLTF_MATERIAL_IOR"); - return { - key: index + 1, - baseColorFactor: pbr.baseColorFactor ?? [1, 1, 1, 1], - metallicFactor: pbr.metallicFactor ?? 1, - roughnessFactor: pbr.roughnessFactor ?? 1, - emissiveFactor: material.emissiveFactor ?? [0, 0, 0], - ior, - alphaMode: (material.alphaMode ?? "OPAQUE").toLowerCase(), - alphaCutoff: material.alphaCutoff ?? 0.5, - doubleSided: material.doubleSided ?? false, - baseColorTexture: textureReference(pbr.baseColorTexture), - metallicRoughnessTexture: textureReference(pbr.metallicRoughnessTexture), - normalTexture: textureReference(material.normalTexture), - normalScale: material.normalTexture?.scale ?? 1, - occlusionTexture: textureReference(material.occlusionTexture), - occlusionStrength: material.occlusionTexture?.strength ?? 1, - emissiveTexture: textureReference(material.emissiveTexture), - }; -} - -function samplerMetadata(sampler) { - const min = sampler.minFilter; - return { - magFilter: sampler.magFilter === 9728 ? "nearest" : "linear", - minFilter: [9728, 9984, 9986].includes(min) ? "nearest" : "linear", - mipmapFilter: [9984, 9985].includes(min) ? "nearest" : "linear", - addressU: sampler.wrapS === 33071 ? "clamp_to_edge" : sampler.wrapS === 33648 ? "mirror_repeat" : "repeat", - addressV: sampler.wrapT === 33071 ? "clamp_to_edge" : sampler.wrapT === 33648 ? "mirror_repeat" : "repeat", - }; -} - -function inferMime(image) { - if (image.mimeType) return image.mimeType; - const uri = image.uri?.toLowerCase() ?? ""; - if (uri.startsWith("data:image/png") || uri.endsWith(".png")) return "image/png"; - if (uri.startsWith("data:image/jpeg") || /\.jpe?g(?:$|[?#])/u.test(uri)) return "image/jpeg"; - fail("GLTF_IMAGE_MIME"); -} - -async function decodeScene(document, buffers, baseUrl, fetcher) { - const geometries = [], occurrences = [], geometryIds = new Map(); - for (let meshIndex = 0; meshIndex < (document.meshes ?? []).length; meshIndex++) { - const mesh = document.meshes[meshIndex]; - for (let primitiveIndex = 0; primitiveIndex < (mesh.primitives ?? []).length; primitiveIndex++) { - const primitive = mesh.primitives[primitiveIndex]; - if ((primitive.mode ?? 4) !== 4 || primitive.attributes?.POSITION === undefined) fail("GLTF_TRIANGLES_REQUIRED"); - const position = readAccessor(document, buffers, primitive.attributes.POSITION); - if (position.width !== 3 || !position.count) fail("GLTF_POSITION_INVALID"); - const normal = primitive.attributes.NORMAL === undefined ? null : readAccessor(document, buffers, primitive.attributes.NORMAL); - const tangent = primitive.attributes.TANGENT === undefined ? null : readAccessor(document, buffers, primitive.attributes.TANGENT); - const texcoord = primitive.attributes.TEXCOORD_0 === undefined ? null : readAccessor(document, buffers, primitive.attributes.TEXCOORD_0); - if (normal && normal.width !== 3 || tangent && tangent.width !== 4 || texcoord && texcoord.width !== 2) - fail("GLTF_ATTRIBUTE_INVALID"); - const uvs = new Float32Array(position.count * 2); - if (texcoord) uvs.set(texcoord.values.subarray(0, uvs.length)); - const indexAccessor = primitive.indices === undefined - ? null - : readAccessor(document, buffers, primitive.indices, { integer: true }); - if (indexAccessor && indexAccessor.width !== 1) fail("GLTF_INDEX_INVALID"); - const indices = indexAccessor?.values - ?? Uint32Array.from({ length: position.count }, (_, index) => index); - const repaired = repairGeometry(position.values, normal?.values, tangent?.values, uvs, indices); - const id = geometries.length; - geometryIds.set(`${meshIndex}:${primitiveIndex}`, id); - const material = primitive.material === undefined ? undefined : document.materials?.[primitive.material]; - geometries.push({ - id, - material: primitive.material === undefined ? 0 : primitive.material + 1, - instanceType: [1 | 4 | (material?.doubleSided ? 8 : 0), ...Array(15).fill(0)], - ...repaired, - }); - } - } - - const children = new Set((document.nodes ?? []).flatMap(node => node.children ?? [])); - const scene = document.scenes?.[document.scene ?? 0]; - const roots = scene?.nodes ?? (document.nodes ?? []).map((_, index) => index).filter(index => !children.has(index)); - const active = new Set(); - const visit = (nodeIndex, parent) => { - const node = document.nodes?.[nodeIndex]; - if (!node || active.has(nodeIndex)) fail("GLTF_NODE_INVALID"); - active.add(nodeIndex); - const world = multiply(parent, nodeMatrix(node)); - if (node.mesh !== undefined) { - const mesh = document.meshes?.[node.mesh]; - if (!mesh) fail("GLTF_MESH_INVALID"); - for (let primitive = 0; primitive < mesh.primitives.length; primitive++) { - const geometry = geometryIds.get(`${node.mesh}:${primitive}`); - if (geometry !== undefined) occurrences.push({ geometry, transform: world }); - } - } - for (const child of node.children ?? []) visit(child, world); - active.delete(nodeIndex); - }; - for (const root of roots) visit(root, IDENTITY); - - const images = await Promise.all((document.images ?? []).map(async image => { - const data = image.bufferView === undefined - ? await fetchBytes(image.uri, baseUrl, fetcher) - : viewBytes(document, buffers, image.bufferView).bytes.slice(); - return { mimeType: inferMime(image), bytes: data }; - })); - return { - geometries, - occurrences, - materials: [materialMetadata({}, -1), ...(document.materials ?? []).map(materialMetadata)], - textures: (document.textures ?? []).map(texture => ({ image: texture.source, sampler: texture.sampler ?? null })), - samplers: (document.samplers ?? []).map(samplerMetadata), - images, - }; -} - -function encodePacket(scene) { - const chunks = []; - let payloadLength = 0; - const append = (source, alignment = 4) => { - const bytes = source instanceof Uint8Array - ? source - : new Uint8Array(source.buffer, source.byteOffset, source.byteLength); - const offset = alignment === 4 ? align4(payloadLength) : payloadLength; - if (offset > payloadLength) chunks.push({ offset: payloadLength, bytes: new Uint8Array(offset - payloadLength) }); - chunks.push({ offset, bytes }); - payloadLength = offset + bytes.byteLength; - return offset; - }; - const stream = (values, width) => ({ offset: append(values), count: values.length / width }); - const metadata = { - geometries: scene.geometries.map(geometry => ({ - id: geometry.id, - material: geometry.material, - instanceType: geometry.instanceType, - positions: stream(geometry.positions, 3), - normals: stream(geometry.normals, 3), - tangents: stream(geometry.tangents, 4), - uvs: stream(geometry.uvs, 2), - indices: stream(geometry.indices, 1), - })), - occurrences: scene.occurrences, - materials: scene.materials, - textures: scene.textures, - samplers: scene.samplers, - images: scene.images.map(image => ({ - mimeType: image.mimeType, - data: { offset: append(image.bytes), byteLength: image.bytes.byteLength }, - })), - }; - const metadataBytes = encoder.encode(JSON.stringify(metadata)); - const payloadOffset = align4(16 + metadataBytes.byteLength); - const packet = new Uint8Array(payloadOffset + payloadLength); - const header = new DataView(packet.buffer); - header.setUint32(0, PACKET_MAGIC, true); - header.setUint32(4, PACKET_VERSION, true); - header.setUint32(8, metadataBytes.byteLength, true); - header.setUint32(12, payloadLength, true); - packet.set(metadataBytes, 16); - for (const chunk of chunks) packet.set(chunk.bytes, payloadOffset + chunk.offset); - return packet; -} - -/** Convert glTF 2.0/GLB bytes into Yawn's format-neutral render-data packet. */ -export async function gltfToRenderDataPacket(source, baseUrl, fetcher = fetch) { - const { document, binary } = parseContainer(source); - if (document.asset?.version !== "2.0") fail("GLTF_VERSION_UNSUPPORTED"); - const buffers = await loadBuffers(document, binary, baseUrl, fetcher); - return encodePacket(await decodeScene(document, buffers, baseUrl, fetcher)); -} diff --git a/addons/gltf-import/src/index.js b/addons/gltf-import/src/index.js deleted file mode 100644 index cfffa7c..0000000 --- a/addons/gltf-import/src/index.js +++ /dev/null @@ -1,62 +0,0 @@ -/** Fetches and parses glTF in a worker, then lets that worker write the packet into Yawn's SAB arena. */ -export class GltfImporter { - #core; - #worker; - #next = 1; - #pending = new Map(); - - constructor(core, { workerFactory } = {}) { - if (!core?.allocateRows) throw new TypeError("core must be a YawnCore instance"); - this.#core = core; - this.#worker = workerFactory?.() ?? new Worker(new URL("./worker.js", import.meta.url), { - type: "module", - name: "yawn-gltf-import", - }); - this.#worker.addEventListener("message", ({ data }) => this.#message(data)); - this.#worker.addEventListener("error", () => this.#fail("GLTF_WORKER_ERROR")); - this.#worker.start?.(); - } - - load(url) { - const source = url instanceof URL ? url.href : url; - if (typeof source !== "string" || !source) throw new TypeError("url is required"); - const request = this.#next++; - return new Promise((resolve, reject) => { - this.#pending.set(request, { resolve, reject }); - this.#worker.postMessage({ type: "load", request, url: source }); - }); - } - - async #message(message) { - const pending = this.#pending.get(message?.request); - if (!pending) return; - try { - if (message.type === "allocate") { - pending.array = await this.#core.allocateRows({ - name: `gltf.${message.request}`, - rows: Math.ceil(message.byteLength / 16), - stride: 16, - format: "u32", - }); - this.#worker.postMessage({ type: "storage", request: message.request, ...pending.array.share() }); - } else { - this.#pending.delete(message.request); - if (message.type === "ready") pending.resolve({ array: pending.array, byteLength: message.byteLength }); - else pending.reject(new Error(message.error ?? "GLTF_IMPORT_FAILED")); - } - } catch (error) { - this.#pending.delete(message.request); - pending.reject(error); - } - } - - #fail(code) { - for (const { reject } of this.#pending.values()) reject(new Error(code)); - this.#pending.clear(); - } - - dispose() { - this.#fail("DISPOSED"); - this.#worker.terminate(); - } -} diff --git a/addons/gltf-import/src/worker.js b/addons/gltf-import/src/worker.js deleted file mode 100644 index 6c8f781..0000000 --- a/addons/gltf-import/src/worker.js +++ /dev/null @@ -1,33 +0,0 @@ -import { gltfToRenderDataPacket } from "./gltf.js"; - -const downloads = new Map(); - -addEventListener("message", async ({ data: message }) => { - const request = message?.request; - try { - if (message?.type === "load") { - const response = await fetch(message.url); - if (!response.ok) throw new Error(`HTTP_${response.status}`); - const bytes = new Uint8Array(await response.arrayBuffer()); - if (!bytes.byteLength) throw new Error("GLTF_EMPTY"); - const packet = await gltfToRenderDataPacket(bytes, message.url); - downloads.set(request, packet); - postMessage({ type: "allocate", request, byteLength: packet.byteLength }); - return; - } - if (message?.type === "storage") { - const packet = downloads.get(request); - if (!packet) throw new Error("GLTF_REQUEST_UNKNOWN"); - const { buffer, descriptor } = message; - if (!(buffer instanceof SharedArrayBuffer) || descriptor?.format !== "u32" || - descriptor.stride !== 16 || descriptor.offset % 64 || packet.byteLength > descriptor.rows * descriptor.stride) - throw new Error("GLTF_STORAGE_INVALID"); - new Uint8Array(buffer, descriptor.offset, packet.byteLength).set(packet); - downloads.delete(request); - postMessage({ type: "ready", request, byteLength: packet.byteLength }); - } - } catch (error) { - downloads.delete(request); - postMessage({ type: "error", request, error: error?.message || "GLTF_IMPORT_FAILED" }); - } -}); diff --git a/addons/handles/package.json b/addons/handles/package.json new file mode 100644 index 0000000..70c6dc9 --- /dev/null +++ b/addons/handles/package.json @@ -0,0 +1,10 @@ +{ + "name": "@yawn/handles", + "version": "0.1.0", + "description": "Conventional scene handles over Yawn's worker and shared render data", + "type": "module", + "exports": "./src/index.ts", + "dependencies": { + "@yawn/core": "0.1.0" + } +} diff --git a/addons/handles/src/ComputePass.ts b/addons/handles/src/ComputePass.ts new file mode 100644 index 0000000..fc7c727 --- /dev/null +++ b/addons/handles/src/ComputePass.ts @@ -0,0 +1,92 @@ +import type { Scene } from "./Scene"; + +export type GraphBuffer = { + id: string; + array: string; + usage?: string[]; +}; + +export type GraphTexture = { + id: string; + format?: string; + size?: [number | "canvas", number | "canvas", number?]; + usage?: string[]; + transient?: boolean; +}; + +export type GraphSampler = { + id: string; + magFilter?: "nearest" | "linear"; + minFilter?: "nearest" | "linear"; +}; + +export type GraphBinding = { + group: number; + binding: number; + resource: string; +}; + +export type ComputePassOptions = { + id?: string; + code: string; + entry?: string; + dispatch?: [number, number?, number?]; + after?: string[]; + bindings?: GraphBinding[]; + buffers?: GraphBuffer[]; + textures?: GraphTexture[]; + samplers?: GraphSampler[]; +}; + +let nextComputePass = 1; + +/** A graph-authored compute pass; attaching or updating it rebuilds the Scene loadout. */ +export class ComputePass { + readonly id: string; + code: string; + entry: string; + dispatch: [number, number, number]; + after: string[]; + bindings: GraphBinding[]; + buffers: GraphBuffer[]; + textures: GraphTexture[]; + samplers: GraphSampler[]; + #scene?: Scene; + + constructor(options: ComputePassOptions) { + if (!options?.code) throw new TypeError("ComputePass code is required"); + this.id = options.id ?? `compute-${nextComputePass++}`; + this.code = options.code; + this.entry = options.entry ?? "main"; + this.dispatch = [ + options.dispatch?.[0] ?? 1, + options.dispatch?.[1] ?? 1, + options.dispatch?.[2] ?? 1, + ]; + this.after = [...(options.after ?? [])]; + this.bindings = [...(options.bindings ?? [])]; + this.buffers = [...(options.buffers ?? [])]; + this.textures = [...(options.textures ?? [])]; + this.samplers = [...(options.samplers ?? [])]; + } + + update(options: Partial>) { + if (options.code !== undefined) this.code = options.code; + if (options.entry !== undefined) this.entry = options.entry; + if (options.dispatch !== undefined) this.dispatch = [ + options.dispatch[0], + options.dispatch[1] ?? 1, + options.dispatch[2] ?? 1, + ]; + if (options.after !== undefined) this.after = [...options.after]; + if (options.bindings !== undefined) this.bindings = [...options.bindings]; + if (options.buffers !== undefined) this.buffers = [...options.buffers]; + if (options.textures !== undefined) this.textures = [...options.textures]; + if (options.samplers !== undefined) this.samplers = [...options.samplers]; + return this.#scene?.updateRenderGraph() ?? Promise.resolve(); + } + + attach(scene?: Scene) { + this.#scene = scene; + } +} diff --git a/addons/handles/src/Mesh.ts b/addons/handles/src/Mesh.ts new file mode 100644 index 0000000..2feb5bb --- /dev/null +++ b/addons/handles/src/Mesh.ts @@ -0,0 +1,132 @@ +import { Node, type NodeOptions } from "./Node"; +import type { Scene } from "./Scene"; +import type { PBRMaterial } from "./materials/PBRMaterial"; + +export const VertexKinds = Object.freeze(["positions", "normals", "tangents", "uvs", "colors", "indices"] as const); +export type VertexKind = (typeof VertexKinds)[number]; +export type MeshOptions = NodeOptions & { + geometryId?: number; + material?: PBRMaterial; + vertexData?: Partial>>; + visible?: boolean; +}; + +/** A renderable Node; clones share geometry until either clone mutates vertex data. */ +export class Mesh extends Node { + geometryId: number; + vertexCount = 0; + indexCount = 0; + instanceOf = -1; + readonly faceMaterials = new Map(); + #registered = false; + + constructor(scene: Scene, options: MeshOptions = {}) { + super(scene, options); + this.geometryId = options.geometryId ?? scene.createGeometry(); + const nodeReady = this.ready; + this.ready = nodeReady.then(async () => { + if (options.material) await options.material.ready; + await scene.ensureRows(`mesh.${this.id}.faceMaterials`, 1, 16, "u32"); + this.vertexCount = Number(scene.geometryData(this.geometryId, "positions")?.length ?? 0) / 3; + this.indexCount = Number(scene.geometryData(this.geometryId, "indices")?.length ?? 0); + this.instanceOf = options.geometryId === undefined ? this.id : this.geometryId; + scene.array("meshInfo").write(this.id, [ + this.geometryId, + options.material?.id ?? 0, + options.visible === false ? 0 : 1, + this.instanceOf, + ]); + for (const [kind, data] of Object.entries(options.vertexData ?? {})) + await this.#setVertexData(kind as VertexKind, data!, false); + this.#writeBounds(); + this.#registered = true; + await scene.registerMesh(this); + return this; + }); + } + + get isVisible() { + if (this.id < 0) throw new Error("Await mesh.ready before reading it"); + return this.scene.array("meshInfo").row(this.id)[2] !== 0; + } + + set isVisible(value: boolean) { + if (this.id < 0) throw new Error("Await mesh.ready before writing it"); + this.scene.array("meshInfo").row(this.id)[2] = value ? 1 : 0; + } + + get materialId() { + return this.scene.array("meshInfo").row(this.id)[1]; + } + + set material(value: PBRMaterial) { + if (value.scene !== this.scene || value.id < 0) throw new Error("Await a material from the same Scene"); + this.scene.array("meshInfo").row(this.id)[1] = value.id; + } + + clone(options: Omit = {}) { + if (this.id < 0) throw new Error("Await mesh.ready before cloning it"); + return new Mesh(this.scene, { ...options, geometryId: this.geometryId }); + } + + async setVertexData(kind: VertexKind, data: ArrayLike) { + if (!VertexKinds.includes(kind)) throw new TypeError(`Unknown vertex kind: ${kind}`); + await this.ready; + await this.#setVertexData(kind, data, true); + return this; + } + + async #setVertexData(kind: VertexKind, data: ArrayLike, makeUnique: boolean) { + if (makeUnique && this.scene.geometryReferences(this.geometryId) > 1) { + const original = this.geometryId; + this.geometryId = await this.scene.cloneGeometry(original); + this.scene.releaseGeometry(original); + this.scene.referenceGeometry(this.geometryId); + this.instanceOf = this.id; + this.scene.array("meshInfo").row(this.id).set([this.geometryId, this.materialId, this.isVisible ? 1 : 0, this.id]); + } + if (kind === "positions") this.vertexCount = data.length / 3; + if (kind === "indices") this.indexCount = data.length; + await this.scene.setVertexData(this.geometryId, kind, data, makeUnique); + if (kind === "positions") this.#writeBounds(); + } + + async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) { + await Promise.all([this.ready, material.ready]); + if (material.scene !== this.scene) throw new Error("Material must belong to the same Scene"); + const list = Array.isArray(faces) ? faces : [faces]; + const maximum = Math.max(...list); + const array = await this.scene.ensureRows(`mesh.${this.id}.faceMaterials`, maximum + 1, 16, "u32"); + for (const face of list) { + if (!Number.isInteger(face) || face < 0) throw new RangeError("face"); + array.row(face)[0] = material.id + 1; + this.faceMaterials.set(face, material.id); + } + await this.scene.updateRenderGraph(); + return this; + } + + #writeBounds() { + const positions = this.scene.geometryData(this.geometryId, "positions"); + if (!positions?.length || this.id < 0) return; + const minimum = [Infinity, Infinity, Infinity]; + const maximum = [-Infinity, -Infinity, -Infinity]; + for (let index = 0; index < positions.length; index += 3) + for (let lane = 0; lane < 3; lane++) { + minimum[lane] = Math.min(minimum[lane], positions[index + lane]); + maximum[lane] = Math.max(maximum[lane], positions[index + lane]); + } + this.scene.array("bounds").row(this.id).set([...minimum, 0, ...maximum, 0]); + } + + override async dispose() { + await this.ready; + if (this.disposed) return; + if (this.#registered) { + this.#registered = false; + await this.scene.unregisterMesh(this); + } + await this.scene.core.deleteRows(`mesh.${this.id}.faceMaterials`); + await super.dispose(); + } +} diff --git a/addons/handles/src/Node.ts b/addons/handles/src/Node.ts new file mode 100644 index 0000000..93c1c5d --- /dev/null +++ b/addons/handles/src/Node.ts @@ -0,0 +1,69 @@ +import type { Scene } from "./Scene"; + +export type NodeOptions = { + position?: ArrayLike; + quaternion?: ArrayLike; + scale?: ArrayLike; + parent?: Node; +}; + +function vector(value: ArrayLike, width: number, name: string) { + if (value.length !== width || Array.from(value).some((lane) => !Number.isFinite(lane))) + throw new TypeError(name); + return value; +} + +/** A thin index into transform SOA rows; transform changes never post a worker message. */ +export class Node { + readonly scene: Scene; + id = -1; + ready: Promise; + protected disposed = false; + + constructor(scene: Scene, options: NodeOptions = {}) { + this.scene = scene; + this.ready = scene.allocateNode().then((id) => { + this.id = id; + if (options.position) this.position = options.position; + if (options.quaternion) this.quaternion = options.quaternion; + if (options.scale) this.scale = options.scale; + if (options.parent) this.parent = options.parent; + return this; + }); + } + + #row(name: string) { + if (this.id < 0) throw new Error("Await node.ready before reading or writing it"); + return this.scene.array(name).row(this.id); + } + + get position(): Float32Array { return this.#row("nodePositions").subarray(0, 3); } + set position(value: ArrayLike) { this.#row("nodePositions").set(vector(value, 3, "position")); } + + get quaternion(): Float32Array { return this.#row("nodeQuaternions").subarray(0, 4); } + set quaternion(value: ArrayLike) { this.#row("nodeQuaternions").set(vector(value, 4, "quaternion")); } + + get scale(): Float32Array { return this.#row("nodeScales").subarray(0, 3); } + set scale(value: ArrayLike) { this.#row("nodeScales").set(vector(value, 3, "scale")); } + + get enabled() { return this.#row("nodes")[0] !== 0; } + set enabled(value: boolean) { this.#row("nodes")[0] = value ? 1 : 0; } + + get parentId() { + const pointer = this.#row("nodes")[1]; + return pointer ? pointer - 1 : null; + } + + set parent(value: Node | null) { + if (value && value.scene !== this.scene) throw new Error("Nodes must belong to the same Scene"); + if (value && value.id < 0) throw new Error("Await parent.ready before assigning it"); + this.#row("nodes")[1] = value ? value.id + 1 : 0; + } + + async dispose() { + await this.ready; + if (this.disposed) return; + this.disposed = true; + await this.scene.releaseNode(this.id); + } +} diff --git a/addons/handles/src/PostProcesses/ColorGrading.ts b/addons/handles/src/PostProcesses/ColorGrading.ts new file mode 100644 index 0000000..c48de9f --- /dev/null +++ b/addons/handles/src/PostProcesses/ColorGrading.ts @@ -0,0 +1,9 @@ +import { PostProcess } from "./PostProcess"; +import type { Scene } from "../Scene"; + +export type ToneMap = "aces" | "reinhard" | "linear"; +export class ColorGrading extends PostProcess { + constructor(scene: Scene, options: { amount?: number; toneMap?: ToneMap; enabled?: boolean } = {}) { + super(scene, "colorGrading", options); + } +} diff --git a/addons/handles/src/PostProcesses/DynamicExposure.ts b/addons/handles/src/PostProcesses/DynamicExposure.ts new file mode 100644 index 0000000..0721ece --- /dev/null +++ b/addons/handles/src/PostProcesses/DynamicExposure.ts @@ -0,0 +1,6 @@ +import { PostProcess } from "./PostProcess"; +import type { Scene } from "../Scene"; + +export class DynamicExposure extends PostProcess { + constructor(scene: Scene, options: { exposure?: number; enabled?: boolean } = {}) { super(scene, "dynamicExposure", options); } +} diff --git a/addons/handles/src/PostProcesses/Edges.ts b/addons/handles/src/PostProcesses/Edges.ts new file mode 100644 index 0000000..e1be6c2 --- /dev/null +++ b/addons/handles/src/PostProcesses/Edges.ts @@ -0,0 +1,6 @@ +import { PostProcess } from "./PostProcess"; +import type { Scene } from "../Scene"; + +export class Edges extends PostProcess { + constructor(scene: Scene, options: { amount?: number; enabled?: boolean } = {}) { super(scene, "edges", options); } +} diff --git a/addons/handles/src/PostProcesses/FXAA.ts b/addons/handles/src/PostProcesses/FXAA.ts new file mode 100644 index 0000000..563125d --- /dev/null +++ b/addons/handles/src/PostProcesses/FXAA.ts @@ -0,0 +1,6 @@ +import { PostProcess } from "./PostProcess"; +import type { Scene } from "../Scene"; + +export class FXAA extends PostProcess { + constructor(scene: Scene, options: { enabled?: boolean } = {}) { super(scene, "fxaa", options); } +} diff --git a/addons/handles/src/PostProcesses/PostProcess.ts b/addons/handles/src/PostProcesses/PostProcess.ts new file mode 100644 index 0000000..84ba1bf --- /dev/null +++ b/addons/handles/src/PostProcesses/PostProcess.ts @@ -0,0 +1,37 @@ +import type { Scene } from "../Scene"; + +let nextPostProcess = 1; + +/** Shared graph-membership behavior for the small post-process handles. */ +export class PostProcess { + readonly scene: Scene; + readonly id: string; + readonly kind: string; + options: Record; + enabled: boolean; + ready: Promise; + + protected constructor(scene: Scene, kind: string, options: Record = {}) { + this.scene = scene; + this.kind = kind; + this.id = `${kind}-${nextPostProcess++}`; + this.enabled = options.enabled !== false; + const { enabled: _, ...effectOptions } = options; + this.options = effectOptions; + this.ready = scene.setPostProcess(this, this.enabled); + } + + setEnabled(enabled: boolean) { + this.enabled = enabled; + this.ready = this.scene.setPostProcess(this, enabled); + return this.ready; + } + + update(options: Record) { + this.options = { ...this.options, ...options }; + this.ready = this.scene.setPostProcess(this, this.enabled); + return this.ready; + } + + dispose() { return this.setEnabled(false); } +} diff --git a/addons/handles/src/PostProcesses/SSAO.ts b/addons/handles/src/PostProcesses/SSAO.ts new file mode 100644 index 0000000..35c1b4e --- /dev/null +++ b/addons/handles/src/PostProcesses/SSAO.ts @@ -0,0 +1,6 @@ +import { PostProcess } from "./PostProcess"; +import type { Scene } from "../Scene"; + +export class SSAO extends PostProcess { + constructor(scene: Scene, options: { amount?: number; enabled?: boolean } = {}) { super(scene, "ssao", options); } +} diff --git a/addons/handles/src/PostProcesses/Silhouette.ts b/addons/handles/src/PostProcesses/Silhouette.ts new file mode 100644 index 0000000..de7ab5f --- /dev/null +++ b/addons/handles/src/PostProcesses/Silhouette.ts @@ -0,0 +1,6 @@ +import { PostProcess } from "./PostProcess"; +import type { Scene } from "../Scene"; + +export class Silhouette extends PostProcess { + constructor(scene: Scene, options: { amount?: number; enabled?: boolean } = {}) { super(scene, "silhouette", options); } +} diff --git a/addons/handles/src/Scene.ts b/addons/handles/src/Scene.ts new file mode 100644 index 0000000..15408eb --- /dev/null +++ b/addons/handles/src/Scene.ts @@ -0,0 +1,586 @@ +import { YawnCore } from "@yawn/core"; +import type { + ComputePass, + GraphBuffer, + GraphSampler, + GraphTexture, +} from "./ComputePass"; + +type RowFormat = "f32" | "u32" | "i32"; +type MeshLike = { + id: number; + geometryId: number; + indexCount: number; + vertexCount: number; + faceMaterials: ReadonlyMap; +}; +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 }; + +const rows = [ + ["nodes", 16, "u32"], + ["nodePositions", 16, "f32"], + ["nodeQuaternions", 16, "f32"], + ["nodeScales", 16, "f32"], + ["meshInfo", 16, "u32"], + ["bounds", 32, "f32"], + ["cameras", 80, "f32"], + ["materials", 48, "f32"], + ["materialTextures", 32, "u32"], + ["pointLights", 32, "f32"], + ["rectAreaLights", 48, "f32"], + ["spotLights", 32, "f32"], + ["directionalLights", 32, "f32"], + ["ambientLights", 16, "f32"], + ["sceneAccent", 16, "f32"], +] as const; + +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 light = 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); + light += pointLights[i * 2u].rgb * pointLights[i * 2u].a * enabled * 0.02; + } + for (var i = 0u; i < arrayLength(&rectLights) / 3u; i++) { + let enabled = rectLights[i * 3u + 1u].z; + count += select(0u, 1u, enabled != 0.0); + light += rectLights[i * 3u].rgb * rectLights[i * 3u].a * enabled * 0.02; + } + for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) { + let enabled = spotLights[i * 2u + 1u].w; + count += select(0u, 1u, enabled != 0.0); + light += spotLights[i * 2u].rgb * spotLights[i * 2u].a * enabled * 0.02; + } + 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); + light += ambientLights[i].rgb * ambientLights[i].a; + } + clusters[0] = count; + clusters[1] = bitcast(light.r); + clusters[2] = bitcast(light.g); + clusters[3] = bitcast(light.b); +}`; + +const forwardShader = /* wgsl */ ` +struct Accent { color: vec4 } +struct VertexOutput { + @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 quaternions: 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 cameras: array>; + +fn rotate(q: vec4, value: vec3) -> vec3 { + return value + 2.0 * cross(q.xyz, cross(q.xyz, value) + q.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(quaternions[instance], point * scales[instance].xyz) + positions[instance].xyz; + var clip = vec4(transformed, 1.0); + if (cameras[2].w != 0.0) { + let cameraNode = u32(cameras[1].x); + let inverse = vec4(-quaternions[cameraNode].xyz, quaternions[cameraNode].w); + let view = rotate(inverse, transformed - positions[cameraNode].xyz); + if (cameras[1].y == 1.0) { + let size = max(cameras[1].z, 0.0001); + clip = vec4(view.x / (size * cameras[0].y * 0.5), view.y / (size * 0.5), -view.z / cameras[0].w, 1.0); + } else { + let focal = 1.0 / tan(cameras[0].x * 0.5); + let depth = (-view.z * cameras[0].w - cameras[0].z * cameras[0].w) / (cameras[0].w - cameras[0].z); + clip = vec4(view.x * focal / cameras[0].y, view.y * focal, depth, -view.z); + } + } + var output: VertexOutput; + output.position = select(vec4(2.0, 2.0, 2.0, 1.0), clip, visible != 0u); + output.normal = normalize(rotate(quaternions[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 light = vec3(0.12 + max(dot(input.normal, normalize(vec3(0.4, 0.7, 0.6))), 0.0) * 0.75) + clusterLight; + let clustered = min(f32(clusters[0]) * 0.002, 0.05); + let color = base.rgb * (light + clustered) * accent.color.rgb * mix(1.0, 1.1, properties.x); + return vec4(color, base.a); +}`; + +const emptyForwardShader = /* wgsl */ ` +struct Accent { color: vec4 } +@group(0) @binding(0) var accent: Accent; +struct VertexOutput { @builtin(position) position: vec4 } +@vertex fn vertex(@builtin(vertex_index) index: u32) -> VertexOutput { + let points = array(vec2(-0.72, -0.6), vec2(0.72, -0.6), vec2(0.0, 0.72)); + var output: VertexOutput; + output.position = vec4(points[index], 0.0, 1.0); + return output; +} +@fragment fn fragment() -> @location(0) vec4 { + return vec4(accent.color.rgb, 1.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; +}`; + +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 presentShader(toneMap: string) { + const tone = toneMap === "reinhard" + ? "color / (color + vec3(1.0))" + : toneMap === "linear" + ? "clamp(color, vec3(0.0), vec3(1.0))" + : "clamp((color * (2.51 * color + vec3(0.03))) / (color * (2.43 * color + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0))"; + return `${fullscreenVertex} +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var sourceSampler: sampler; +@fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { + let value = textureSample(source, sourceSampler, input.uv); + let color = value.rgb; + return vec4(${tone}, 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)})`; +} + +/** 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; + #graphUpdates = Promise.resolve(); + #computePasses = new Map(); + #meshes = new Map(); + #shaders = new Map(); + #effects = new Map(); + #textures = new Map(); + #geometry = new Map>(); + #geometryRefs = new Map(); + #nextGeometry = 1; + #nextTexture = 0; + + constructor(canvas: HTMLCanvasElement, options: { arenaBytes?: number; fps?: number; hdr?: boolean } = {}) { + this.hdr = options.hdr ?? true; + this.core = new YawnCore(canvas, { arenaBytes: options.arenaBytes }); + this.ready = this.#initialize(options.fps ?? 60); + } + + async #initialize(fps: number) { + await this.core.ready; + 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("nodeQuaternions").write(0, [0, 0, 0, 1]); + this.core.array("nodeScales").write(0, [1, 1, 1, 0]); + this.core.array("sceneAccent").write(0, [0.28, 0.72, 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, 0, 1, 0.5]); + await this.core.setFps(fps); + await this.#compileRenderGraph(); + return this; + } + + array(name: string) { + return this.core.array(name); + } + + 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 }); + } + + async allocateNode() { + await this.ready; + const id = await this.core.allocateObject("nodes"); + const growth = rows.slice(1, 6).flatMap(([name, stride, format]) => { + const current = this.array(name); + return current.rows < id + 1 ? [{ name, rows: id + 1, stride, format }] : []; + }); + if (growth.length) await this.core.createRowsBatch(growth); + this.array("nodeQuaternions").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", "nodeQuaternions", "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.ensureRows("materialTextures", id + 1, 32, "u32"); + return id; + } + + 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.#geometryRefs.set(mesh.geometryId, (this.#geometryRefs.get(mesh.geometryId) ?? 0) + 1); + return this.updateRenderGraph(); + } + + async unregisterMesh(mesh: MeshLike) { + this.#meshes.delete(mesh.id); + const references = Math.max(0, (this.#geometryRefs.get(mesh.geometryId) ?? 1) - 1); + this.#geometryRefs.set(mesh.geometryId, references); + await this.updateRenderGraph(); + if (!references) { + for (const kind of this.#geometry.get(mesh.geometryId)?.keys() ?? []) + await this.core.deleteRows(`geometry.${mesh.geometryId}.${kind}`); + this.#geometry.delete(mesh.geometryId); + this.#geometryRefs.delete(mesh.geometryId); + } + } + + createGeometry() { + const id = this.#nextGeometry++; + this.#geometry.set(id, new Map()); + this.#geometryRefs.set(id, 0); + return id; + } + + geometryReferences(id: number) { + return this.#geometryRefs.get(id) ?? 0; + } + + referenceGeometry(id: number) { + this.#geometryRefs.set(id, (this.#geometryRefs.get(id) ?? 0) + 1); + } + + releaseGeometry(id: number) { + this.#geometryRefs.set(id, Math.max(0, (this.#geometryRefs.get(id) ?? 1) - 1)); + } + + async cloneGeometry(id: number) { + const clone = this.createGeometry(); + for (const [kind, data] of this.#geometry.get(id) ?? []) + await this.setVertexData(clone, kind, data.slice() as Float32Array | Uint32Array, false); + return clone; + } + + async setVertexData(geometry: number, kind: string, source: ArrayLike, updateGraph = true) { + const components: Record = { positions: 3, normals: 3, tangents: 4, uvs: 2, colors: 4, indices: 1 }; + const width = components[kind]; + if (!width || source.length % width) throw new RangeError(`VERTEX_DATA: ${kind}`); + const integer = kind === "indices"; + const data = integer ? Uint32Array.from(source) : Float32Array.from(source); + const name = `geometry.${geometry}.${kind}`; + const rowCount = integer ? Math.ceil(data.length / 4) : data.length / width; + const target = await this.ensureRows(name, rowCount, 16, integer ? "u32" : "f32"); + target.view.fill(0); + if (integer) target.view.set(data); + else for (let row = 0; row < rowCount; row++) + target.row(row).set(data.subarray(row * width, (row + 1) * width)); + (this.#geometry.get(geometry) ?? this.#geometry.set(geometry, new Map()).get(geometry)!) + .set(kind, data); + if (updateGraph) await this.updateRenderGraph(); + } + + geometryData(id: number, kind: string) { + return this.#geometry.get(id)?.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(); + } + + updateRenderGraph() { + const update = this.#graphUpdates.then(async () => { + await this.ready; + await this.#compileRenderGraph(); + }); + this.#graphUpdates = update.catch(() => undefined); + return update; + } + + async #compileRenderGraph() { + 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 renderedMeshes = [...this.#meshes.values()].filter((mesh) => mesh.vertexCount > 0); + const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm"; + addTexture({ id: "hdr", format: hdrFormat, size: ["canvas", "canvas", 1], usage: ["render", "sampled"], transient: false }); + addSampler({ id: "linear", magFilter: "linear", minFilter: "linear" }); + + let previous = computeIds.length ? computeIds : ["cluster-lights"]; + if (!renderedMeshes.length) { + renderPipelines.push({ id: "empty-forward", code: emptyForwardShader, vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }] } }); + passes.push({ + id: "forward-empty", type: "render", pipeline: "empty-forward", after: previous, + bindings: [{ group: 0, binding: 0, resource: "accent" }], + color: [{ resource: "hdr", clear: [0.015, 0.025, 0.05, 1] }], draw: { vertices: 3 }, + }); + previous = ["forward-empty"]; + } else { + for (const [id, array] of [ + ["node-positions", "nodePositions"], ["node-quaternions", "nodeQuaternions"], + ["node-scales", "nodeScales"], ["mesh-info", "meshInfo"], ["materials", "materials"], ["cameras", "cameras"], + ]) addBuffer({ id, array, usage: ["storage"] }); + renderPipelines.push({ + id: "forward-pbr", code: forwardShader, + vertex: { entry: "vertex", buffers: [{ arrayStride: 16, attributes: [{ format: "float32x3", offset: 0, shaderLocation: 0 }] }] }, + fragment: { entry: "fragment", targets: [{ format: hdrFormat }] }, + }); + let firstRender = true; + for (const mesh of renderedMeshes) { + const vertex = `geometry-${mesh.geometryId}-positions`; + addBuffer({ id: vertex, array: `geometry.${mesh.geometryId}.positions`, usage: ["vertex"] }); + const indexed = mesh.indexCount > 0; + if (indexed) addBuffer({ id: `geometry-${mesh.geometryId}-indices`, array: `geometry.${mesh.geometryId}.indices`, usage: ["index"] }); + const draws = indexed && mesh.faceMaterials.size + ? Array.from({ length: Math.floor(mesh.indexCount / 3) }, (_, face) => ({ + face, + count: 3, + firstIndex: face * 3, + material: mesh.faceMaterials.get(face), + })) + : [{ face: -1, count: indexed ? mesh.indexCount : mesh.vertexCount, firstIndex: 0, material: undefined }]; + for (const draw of draws) { + const id = `forward-${mesh.id}-${draw.face}`; + if (mesh.id > 65535 || (draw.material ?? 0) > 65534) throw new RangeError("Scene handle limit"); + const instance = (mesh.id + (draw.material === undefined ? 0 : (draw.material + 1) * 65536)) >>> 0; + passes.push({ + id, type: "render", pipeline: "forward-pbr", after: previous, + bindings: ["clusters", "accent", "node-positions", "node-quaternions", "node-scales", "mesh-info", "materials", "cameras"] + .map((resource, binding) => ({ group: 0, binding, resource })), + color: [{ resource: "hdr", ...(firstRender ? { clear: [0.015, 0.025, 0.05, 1] } : { load: "load" }) }], + vertexBuffers: [{ slot: 0, resource: vertex }], + ...(indexed ? { indexBuffer: { resource: `geometry-${mesh.geometryId}-indices`, format: "uint32" } } : {}), + draw: indexed + ? { indices: draw.count, firstIndex: draw.firstIndex, instances: 1, firstInstance: instance } + : { vertices: draw.count, instances: 1, firstInstance: instance }, + }); + firstRender = false; + previous = [id]; + } + } + } + + 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]; + } + + for (const { number, source: _, ...texture } of this.#textures.values()) { + addTexture(texture); + const id = `retain-texture-${number}`; + computePipelines.push({ + id, + code: "@group(0) @binding(0) var source: texture_2d; @group(0) @binding(1) var output: array; @compute @workgroup_size(1) fn main() { output[1] = textureDimensions(source).x; }", + entry: "main", + }); + passes.push({ + id, type: "compute", pipeline: id, after: ["cluster-lights"], dispatch: [1, 1, 1], + bindings: [{ group: 0, binding: 0, resource: texture.id }, { group: 0, binding: 1, resource: "clusters" }], + }); + } + const toneMap = String([...this.#effects.values()].find((effect) => effect.kind === "colorGrading")?.options.toneMap ?? "aces"); + renderPipelines.push({ id: "present", code: presentShader(toneMap), 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" }], + color: [{ resource: "canvas", clear: [0, 0, 0, 1] }], 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, + }; + const id = await this.core.compileGraph(serialize(graph)); + await this.core.switchLoadout(id); + } + + dispose() { + this.core.dispose(); + } +} diff --git a/addons/handles/src/bvh/picking.ts b/addons/handles/src/bvh/picking.ts new file mode 100644 index 0000000..0f6d87f --- /dev/null +++ b/addons/handles/src/bvh/picking.ts @@ -0,0 +1,56 @@ +import type { Scene } from "../Scene"; + +export type PickHit = { id: number; distance: number }; + +/** Worker-backed broad-phase picking that returns every SAB AABB hit, nearest first. */ +export class Picking { + readonly scene: Scene; + readonly ready: Promise; + #worker: Worker; + #next = 1; + #pending = new Map void; reject: (error: Error) => void }>(); + + constructor(scene: Scene) { + this.scene = scene; + this.#worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module", name: "yawn-bvh" }); + this.#worker.addEventListener("message", ({ data }) => { + const pending = this.#pending.get(data.request); + if (!pending) return; + this.#pending.delete(data.request); + pending.resolve(data.hits); + }); + this.#worker.addEventListener("error", () => this.#fail(new Error("BVH_WORKER_ERROR"))); + this.ready = scene.ready.then(() => this.refresh()); + } + + refresh() { + return this.#request("sync", { + shares: Object.fromEntries(["info", "nodes", "nodePositions", "meshInfo", "bounds"] + .map((name) => [name, this.scene.array(name).share()])), + }).then(() => undefined); + } + + async pick(origin: ArrayLike, direction: ArrayLike): Promise { + await this.ready; + if (origin.length !== 3 || direction.length !== 3) throw new TypeError("Pick rays have three lanes"); + return this.#request("pick", { origin: Array.from(origin), direction: Array.from(direction) }); + } + + #request(type: string, payload: object) { + const request = this.#next++; + return new Promise((resolve, reject) => { + this.#pending.set(request, { resolve, reject }); + this.#worker.postMessage({ type, request, ...payload }); + }); + } + + #fail(error: Error) { + for (const pending of this.#pending.values()) pending.reject(error); + this.#pending.clear(); + } + + dispose() { + this.#fail(new Error("DISPOSED")); + this.#worker.terminate(); + } +} diff --git a/addons/handles/src/bvh/worker.ts b/addons/handles/src/bvh/worker.ts new file mode 100644 index 0000000..f9a126a --- /dev/null +++ b/addons/handles/src/bvh/worker.ts @@ -0,0 +1,97 @@ +type SharedRows = { buffer: SharedArrayBuffer; descriptor: { offset: number; rows: number; stride: number; format: string } }; +type Box = { id: number; min: number[]; max: number[] }; +type Branch = { min: number[]; max: number[]; boxes?: Box[]; left?: Branch; right?: Branch }; + +let shares: Record = {}; +let root: Branch | undefined; +let builtFrame = -1; + +function view(name: string) { + const share = shares[name]; + if (!share) return undefined; + const length = share.descriptor.rows * share.descriptor.stride / 4; + return share.descriptor.format === "u32" + ? new Uint32Array(share.buffer, share.descriptor.offset, length) + : new Float32Array(share.buffer, share.descriptor.offset, length); +} + +function merge(boxes: Box[]) { + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + for (const box of boxes) for (let lane = 0; lane < 3; lane++) { + min[lane] = Math.min(min[lane], box.min[lane]); + max[lane] = Math.max(max[lane], box.max[lane]); + } + return { min, max }; +} + +function build(boxes: Box[]): Branch | undefined { + if (!boxes.length) return undefined; + const bounds = merge(boxes); + if (boxes.length <= 4) return { ...bounds, boxes }; + const extents = bounds.max.map((value, lane) => value - bounds.min[lane]); + const axis = extents.indexOf(Math.max(...extents)); + boxes.sort((a, b) => (a.min[axis] + a.max[axis]) - (b.min[axis] + b.max[axis])); + const middle = Math.ceil(boxes.length / 2); + return { ...bounds, left: build(boxes.slice(0, middle)), right: build(boxes.slice(middle)) }; +} + +function rebuild() { + const bounds = view("bounds"); + const positions = view("nodePositions"); + const meshes = view("meshInfo"); + const nodes = view("nodes"); + if (!bounds || !positions || !meshes || !nodes) return; + const count = shares.bounds.descriptor.rows; + const boxes: Box[] = []; + for (let id = 0; id < count; id++) { + if (!nodes[id * 4] || !meshes[id * 4 + 2]) continue; + const offset = id * 8; + const translation = id * 4; + const min = [0, 1, 2].map((lane) => Number(bounds[offset + lane]) + Number(positions[translation + lane])); + const max = [0, 1, 2].map((lane) => Number(bounds[offset + 4 + lane]) + Number(positions[translation + lane])); + if (min.every(Number.isFinite) && max.every(Number.isFinite)) boxes.push({ id, min, max }); + } + root = build(boxes); + builtFrame = Number(view("info")?.[1] ?? builtFrame + 1); +} + +function intersection(origin: number[], inverse: number[], min: number[], max: number[]) { + let near = -Infinity; + let far = Infinity; + for (let lane = 0; lane < 3; lane++) { + const a = (min[lane] - origin[lane]) * inverse[lane]; + const b = (max[lane] - origin[lane]) * inverse[lane]; + near = Math.max(near, Math.min(a, b)); + far = Math.min(far, Math.max(a, b)); + } + return far >= Math.max(near, 0) ? Math.max(near, 0) : Infinity; +} + +function trace(branch: Branch | undefined, origin: number[], inverse: number[], hits: { id: number; distance: number }[]) { + if (!branch || !Number.isFinite(intersection(origin, inverse, branch.min, branch.max))) return; + for (const box of branch.boxes ?? []) { + const distance = intersection(origin, inverse, box.min, box.max); + if (Number.isFinite(distance)) hits.push({ id: box.id, distance }); + } + trace(branch.left, origin, inverse, hits); + trace(branch.right, origin, inverse, hits); +} + +addEventListener("message", ({ data }) => { + if (data.type === "sync") { + shares = data.shares; + rebuild(); + postMessage({ type: "synced", request: data.request }); + return; + } + if (data.type === "pick") { + const frame = Number(view("info")?.[1] ?? -1); + if (frame !== builtFrame) rebuild(); + const inverse = data.direction.map((lane: number) => 1 / lane); + const hits: { id: number; distance: number }[] = []; + trace(root, data.origin, inverse, hits); + hits.sort((a, b) => a.distance - b.distance); + postMessage({ type: "hits", request: data.request, hits }); + } +}); diff --git a/addons/handles/src/camera/ArcRotateCamera.ts b/addons/handles/src/camera/ArcRotateCamera.ts new file mode 100644 index 0000000..05b1415 --- /dev/null +++ b/addons/handles/src/camera/ArcRotateCamera.ts @@ -0,0 +1,148 @@ +import type { Node } from "../Node"; +import type { Scene } from "../Scene"; +import { Camera, type CameraOptions } from "./Camera"; + +export type ArcRotateControls = { + element: HTMLElement; + pointer?: boolean; + controller?: boolean; + orbitSpeed?: number; + panSpeed?: number; + zoomSpeed?: number; +}; +export type ArcRotateCameraOptions = CameraOptions & { + target?: Node; + targetPosition?: ArrayLike; + alpha?: number; + beta?: number; + radius?: number; + controls?: ArcRotateControls; +}; + +/** Orbit/pan/zoom camera whose controls mutate only its transform and camera SAB rows. */ +export class ArcRotateCamera extends Camera { + #target?: Node; + #controls?: ArcRotateControls; + #pointer?: { x: number; y: number; button: number }; + #frame = 0; + + constructor(scene: Scene, options: ArcRotateCameraOptions = {}) { + super(scene, options); + this.#target = options.target; + const cameraReady = this.ready; + this.ready = cameraReady.then(async () => { + if (this.#target) await this.#target.ready; + const row = this.cameraRow(); + row[12] = this.#target ? this.#target.id + 1 : 0; + row[13] = options.alpha ?? 0; + row[14] = options.beta ?? Math.PI / 3; + row[15] = options.radius ?? 5; + row.set(Array.from(options.targetPosition ?? [0, 0, 0]), 16); + row[19] = 1; + this.#updateTransform(); + if (options.controls) this.attachControls(options.controls); + return this; + }); + } + + get alpha() { return this.cameraRow()[13]; } + set alpha(value: number) { this.cameraRow()[13] = value; this.#updateTransform(); } + get beta() { return this.cameraRow()[14]; } + set beta(value: number) { this.cameraRow()[14] = Math.min(Math.PI - 0.001, Math.max(0.001, value)); this.#updateTransform(); } + get radius() { return this.cameraRow()[15]; } + set radius(value: number) { this.cameraRow()[15] = Math.max(0.01, value); this.#updateTransform(); } + + get target() { return this.#target; } + set target(value: Node | undefined) { + if (value && (value.scene !== this.scene || value.id < 0)) throw new Error("Target must be a ready Node in this Scene"); + this.#target = value; + this.cameraRow()[12] = value ? value.id + 1 : 0; + this.#updateTransform(); + } + + attachControls(controls: ArcRotateControls) { + this.detachControls(); + this.#controls = controls; + if (controls.pointer !== false) { + controls.element.addEventListener("pointerdown", this.#down); + controls.element.addEventListener("pointermove", this.#move); + controls.element.addEventListener("pointerup", this.#up); + controls.element.addEventListener("wheel", this.#wheel, { passive: false }); + } + if (controls.controller) this.#frame = requestAnimationFrame(this.#pollController); + return this; + } + + detachControls() { + const element = this.#controls?.element; + if (element) { + element.removeEventListener("pointerdown", this.#down); + element.removeEventListener("pointermove", this.#move); + element.removeEventListener("pointerup", this.#up); + element.removeEventListener("wheel", this.#wheel); + } + cancelAnimationFrame(this.#frame); + this.#frame = 0; + this.#pointer = undefined; + this.#controls = undefined; + } + + #targetPosition() { + return this.#target ? this.#target.position : this.cameraRow().subarray(16, 19); + } + + #updateTransform() { + if (this.id < 0 || this.cameraId < 0) return; + const target = this.#targetPosition(); + const sinBeta = Math.sin(this.beta); + this.position = [ + target[0] + this.radius * sinBeta * Math.sin(this.alpha), + target[1] + this.radius * Math.cos(this.beta), + target[2] + this.radius * sinBeta * Math.cos(this.alpha), + ]; + this.lookAt(target); + } + + #down = (event: PointerEvent) => { + this.#pointer = { x: event.clientX, y: event.clientY, button: event.button }; + this.#controls?.element.setPointerCapture?.(event.pointerId); + }; + #up = () => { this.#pointer = undefined; }; + #move = (event: PointerEvent) => { + if (!this.#pointer || !this.#controls) return; + const x = event.clientX - this.#pointer.x; + const y = event.clientY - this.#pointer.y; + this.#pointer.x = event.clientX; + this.#pointer.y = event.clientY; + if (this.#pointer.button === 2) { + const target = this.#targetPosition(); + target[0] -= x * (this.#controls.panSpeed ?? 0.005); + target[1] += y * (this.#controls.panSpeed ?? 0.005); + this.#updateTransform(); + } else { + const speed = this.#controls.orbitSpeed ?? 0.005; + this.cameraRow()[13] -= x * speed; + this.cameraRow()[14] = Math.min(Math.PI - 0.001, Math.max(0.001, this.beta + y * speed)); + this.#updateTransform(); + } + }; + #wheel = (event: WheelEvent) => { + event.preventDefault(); + this.radius *= Math.exp(event.deltaY * (this.#controls?.zoomSpeed ?? 0.001)); + }; + #pollController = () => { + const pad = navigator.getGamepads?.().find(Boolean); + if (pad) { + this.cameraRow()[13] += (pad.axes[2] ?? pad.axes[0] ?? 0) * 0.03; + this.cameraRow()[14] = Math.min(Math.PI - 0.001, Math.max(0.001, this.beta + (pad.axes[3] ?? pad.axes[1] ?? 0) * 0.03)); + this.radius += ((pad.buttons[6]?.value ?? 0) - (pad.buttons[7]?.value ?? 0)) * 0.1; + this.#updateTransform(); + } + this.#frame = requestAnimationFrame(this.#pollController); + }; + + override async dispose() { + this.detachControls(); + await super.dispose(); + } +} diff --git a/addons/handles/src/camera/Camera.ts b/addons/handles/src/camera/Camera.ts new file mode 100644 index 0000000..dd8beeb --- /dev/null +++ b/addons/handles/src/camera/Camera.ts @@ -0,0 +1,96 @@ +import { Node, type NodeOptions } from "../Node"; +import type { Scene } from "../Scene"; + +export type CameraOptions = NodeOptions & { + fov?: number; + near?: number; + far?: number; + aspect?: number; + projection?: "perspective" | "orthographic"; + orthoSize?: number; + focalLength?: number; + aperture?: number; + focusDistance?: number; + sensorWidth?: number; +}; + +/** Conventional camera data; core only sees another generically allocated shared row. */ +export class Camera extends Node { + cameraId = -1; + #cameraDisposed = false; + + constructor(scene: Scene, options: CameraOptions = {}) { + super(scene, options); + const nodeReady = this.ready; + this.ready = nodeReady.then(async () => { + this.cameraId = await scene.core.allocateObject("cameras"); + const row = scene.array("cameras").row(this.cameraId); + row.set([ + options.fov ?? Math.PI / 3, + options.aspect ?? 1, + options.near ?? 0.1, + options.far ?? 1000, + this.id, + options.projection === "orthographic" ? 1 : 0, + options.orthoSize ?? 10, + options.focalLength ?? 50, + options.aperture ?? 2.8, + options.focusDistance ?? 10, + options.sensorWidth ?? 36, + 1, + ]); + return this; + }); + } + + protected cameraRow() { + if (this.cameraId < 0) throw new Error("Await camera.ready before reading or writing it"); + return this.scene.array("cameras").row(this.cameraId); + } + + get fov() { return this.cameraRow()[0]; } + set fov(value: number) { this.cameraRow()[0] = value; } + get aspect() { return this.cameraRow()[1]; } + set aspect(value: number) { this.cameraRow()[1] = value; } + get near() { return this.cameraRow()[2]; } + set near(value: number) { this.cameraRow()[2] = value; } + get far() { return this.cameraRow()[3]; } + set far(value: number) { this.cameraRow()[3] = value; } + get projection() { return this.cameraRow()[5] === 1 ? "orthographic" : "perspective"; } + set projection(value: "perspective" | "orthographic") { this.cameraRow()[5] = value === "orthographic" ? 1 : 0; } + get orthoSize() { return this.cameraRow()[6]; } + set orthoSize(value: number) { this.cameraRow()[6] = value; } + get focalLength() { return this.cameraRow()[7]; } + set focalLength(value: number) { this.cameraRow()[7] = value; } + get aperture() { return this.cameraRow()[8]; } + set aperture(value: number) { this.cameraRow()[8] = value; } + get focusDistance() { return this.cameraRow()[9]; } + set focusDistance(value: number) { this.cameraRow()[9] = value; } + get sensorWidth() { return this.cameraRow()[10]; } + set sensorWidth(value: number) { this.cameraRow()[10] = value; } + + lookAt(target: ArrayLike) { + if (target.length !== 3) throw new RangeError("camera target"); + const position = this.position; + const x = target[0] - position[0]; + const y = target[1] - position[1]; + const z = target[2] - position[2]; + const length = Math.hypot(x, y, z) || 1; + const direction = [x / length, y / length, z / length]; + if (direction[2] > 0.999999) this.quaternion = [0, 1, 0, 0]; + else { + const q = [direction[1], -direction[0], 0, 1 - direction[2]]; + const qLength = Math.hypot(...q); + this.quaternion = q.map((lane) => lane / qLength); + } + return this; + } + + override async dispose() { + await this.ready; + if (this.#cameraDisposed) return; + this.#cameraDisposed = true; + await this.scene.core.deleteObject("cameras", this.cameraId); + await super.dispose(); + } +} diff --git a/addons/handles/src/camera/FollowCamera.ts b/addons/handles/src/camera/FollowCamera.ts new file mode 100644 index 0000000..c112898 --- /dev/null +++ b/addons/handles/src/camera/FollowCamera.ts @@ -0,0 +1,82 @@ +import type { Node } from "../Node"; +import type { Scene } from "../Scene"; +import { Camera, type CameraOptions } from "./Camera"; + +export type FollowCameraOptions = CameraOptions & { + target: Node; + distance?: number; + height?: number; + smoothing?: number; + running?: boolean; +}; + +/** Third-person camera that follows a target Node by direct shared-row reads and writes. */ +export class FollowCamera extends Camera { + #target: Node; + #frame = 0; + #smoothing: number; + + constructor(scene: Scene, options: FollowCameraOptions) { + if (!options?.target) throw new TypeError("FollowCamera target is required"); + super(scene, options); + this.#target = options.target; + this.#smoothing = options.smoothing ?? 0.12; + const cameraReady = this.ready; + this.ready = cameraReady.then(async () => { + await this.#target.ready; + if (this.#target.scene !== scene) throw new Error("Target must belong to this Scene"); + const row = this.cameraRow(); + row[12] = this.#target.id + 1; + row[15] = options.distance ?? 6; + row[16] = options.height ?? 2; + row[19] = 3; + this.#snap(); + if (options.running !== false) this.start(); + return this; + }); + } + + get target() { return this.#target; } + set target(value: Node) { + if (value.scene !== this.scene || value.id < 0) throw new Error("Target must be a ready Node in this Scene"); + this.#target = value; + this.cameraRow()[12] = value.id + 1; + } + get distance() { return this.cameraRow()[15]; } + set distance(value: number) { this.cameraRow()[15] = Math.max(0, value); } + get height() { return this.cameraRow()[16]; } + set height(value: number) { this.cameraRow()[16] = value; } + get smoothing() { return this.#smoothing; } + set smoothing(value: number) { this.#smoothing = Math.min(1, Math.max(0, value)); } + + start() { + if (!this.#frame) this.#frame = requestAnimationFrame(this.#follow); + return this; + } + + stop() { + cancelAnimationFrame(this.#frame); + this.#frame = 0; + return this; + } + + #snap() { + const target = this.#target.position; + this.position = [target[0], target[1] + this.height, target[2] + this.distance]; + this.lookAt(target); + } + + #follow = () => { + const target = this.#target.position; + const desired = [target[0], target[1] + this.height, target[2] + this.distance]; + const position = this.position; + for (let lane = 0; lane < 3; lane++) position[lane] += (desired[lane] - position[lane]) * this.#smoothing; + this.lookAt(target); + this.#frame = requestAnimationFrame(this.#follow); + }; + + override async dispose() { + this.stop(); + await super.dispose(); + } +} diff --git a/addons/handles/src/camera/FreeCamera.ts b/addons/handles/src/camera/FreeCamera.ts new file mode 100644 index 0000000..c25830f --- /dev/null +++ b/addons/handles/src/camera/FreeCamera.ts @@ -0,0 +1,101 @@ +import type { Scene } from "../Scene"; +import { Camera, type CameraOptions } from "./Camera"; + +export type FreeCameraControls = { + element: HTMLElement; + keyboard?: boolean; + pointer?: boolean; + controller?: boolean; + speed?: number; + sensitivity?: number; +}; +export type FreeCameraOptions = CameraOptions & { controls?: FreeCameraControls }; + +/** Spectator-style WASD/mouse/gamepad camera backed entirely by shared camera and transform rows. */ +export class FreeCamera extends Camera { + #controls?: FreeCameraControls; + #keys = new Set(); + #frame = 0; + #last = 0; + + constructor(scene: Scene, options: FreeCameraOptions = {}) { + super(scene, options); + const cameraReady = this.ready; + this.ready = cameraReady.then(() => { + this.cameraRow()[19] = 2; + if (options.controls) this.attachControls(options.controls); + return this; + }); + } + + attachControls(controls: FreeCameraControls) { + this.detachControls(); + this.#controls = controls; + if (controls.keyboard !== false) { + addEventListener("keydown", this.#keyDown); + addEventListener("keyup", this.#keyUp); + } + if (controls.pointer !== false) { + controls.element.addEventListener("click", this.#lock); + addEventListener("mousemove", this.#mouse); + } + this.#last = performance.now(); + this.#frame = requestAnimationFrame(this.#update); + return this; + } + + detachControls() { + const element = this.#controls?.element; + if (element) element.removeEventListener("click", this.#lock); + removeEventListener("keydown", this.#keyDown); + removeEventListener("keyup", this.#keyUp); + removeEventListener("mousemove", this.#mouse); + cancelAnimationFrame(this.#frame); + this.#frame = 0; + this.#keys.clear(); + this.#controls = undefined; + } + + #keyDown = (event: KeyboardEvent) => this.#keys.add(event.code); + #keyUp = (event: KeyboardEvent) => this.#keys.delete(event.code); + #lock = () => this.#controls?.element.requestPointerLock?.(); + #mouse = (event: MouseEvent) => { + if (!this.#controls || document.pointerLockElement !== this.#controls.element) return; + const sensitivity = this.#controls.sensitivity ?? 0.002; + this.cameraRow()[13] -= event.movementX * sensitivity; + this.cameraRow()[14] = Math.min(1.55, Math.max(-1.55, this.cameraRow()[14] - event.movementY * sensitivity)); + this.#writeQuaternion(); + }; + + #writeQuaternion() { + const yaw = this.cameraRow()[13]; + const pitch = this.cameraRow()[14]; + const sy = Math.sin(yaw / 2), cy = Math.cos(yaw / 2); + const sx = Math.sin(pitch / 2), cx = Math.cos(pitch / 2); + this.quaternion = [sx * cy, cx * sy, -sx * sy, cx * cy]; + } + + #update = (time: number) => { + if (!this.#controls) return; + const delta = Math.min(0.1, (time - this.#last) / 1000); + this.#last = time; + let x = Number(this.#keys.has("KeyD")) - Number(this.#keys.has("KeyA")); + let y = Number(this.#keys.has("Space")) - Number(this.#keys.has("ControlLeft")); + let z = Number(this.#keys.has("KeyW")) - Number(this.#keys.has("KeyS")); + if (this.#controls.controller) { + const pad = navigator.getGamepads?.().find(Boolean); + if (pad) { x += pad.axes[0] ?? 0; y -= pad.axes[3] ?? 0; z -= pad.axes[1] ?? 0; } + } + const speed = (this.#controls.speed ?? 4) * delta; + const yaw = this.cameraRow()[13]; + this.position[0] += (x * Math.cos(yaw) + z * Math.sin(yaw)) * speed; + this.position[1] += y * speed; + this.position[2] += (x * -Math.sin(yaw) + z * Math.cos(yaw)) * speed; + this.#frame = requestAnimationFrame(this.#update); + }; + + override async dispose() { + this.detachControls(); + await super.dispose(); + } +} diff --git a/addons/handles/src/importers/gltf.ts b/addons/handles/src/importers/gltf.ts new file mode 100644 index 0000000..c0dc1b2 --- /dev/null +++ b/addons/handles/src/importers/gltf.ts @@ -0,0 +1,56 @@ +import { Mesh } from "../Mesh"; +import type { Scene } from "../Scene"; +import { PBRMaterial } from "../materials/PBRMaterial"; + +let worker: Worker | undefined; +let nextRequest = 1; +const pending = new Map void; reject: (error: Error) => void }>(); + +function importer() { + if (worker) return worker; + worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module", name: "yawn-importer" }); + worker.addEventListener("message", ({ data }) => { + const request = pending.get(data.request); + if (!request) return; + pending.delete(data.request); + if (data.error) request.reject(new Error(data.error)); + else request.resolve(data.result); + }); + worker.addEventListener("error", () => { + for (const request of pending.values()) request.reject(new Error("IMPORT_WORKER_ERROR")); + pending.clear(); + }); + return worker; +} + +/** Imports glTF/GLB off-thread, then hydrates conventional handles backed by Scene SAB rows. */ +export async function importGltf(scene: Scene, url: string | URL) { + await scene.ready; + const request = nextRequest++; + const result = await new Promise((resolve, reject) => { + pending.set(request, { resolve, reject }); + importer().postMessage({ request, url: String(url) }); + }); + const materials = result.materials.map((options: any) => new PBRMaterial(scene, options)); + await Promise.all(materials.map((material: PBRMaterial) => material.ready)); + const meshes: Mesh[] = []; + for (const primitive of result.primitives) { + const mesh = new Mesh(scene, { + position: primitive.position, + quaternion: primitive.quaternion, + scale: primitive.scale, + material: materials[primitive.material], + vertexData: { + positions: primitive.positions, + indices: primitive.indices, + ...(primitive.normals ? { normals: primitive.normals } : {}), + ...(primitive.tangents ? { tangents: primitive.tangents } : {}), + ...(primitive.uvs ? { uvs: primitive.uvs } : {}), + ...(primitive.colors ? { colors: primitive.colors } : {}), + }, + }); + await mesh.ready; + meshes.push(mesh); + } + return meshes; +} diff --git a/addons/handles/src/importers/worker.ts b/addons/handles/src/importers/worker.ts new file mode 100644 index 0000000..abe7d9f --- /dev/null +++ b/addons/handles/src/importers/worker.ts @@ -0,0 +1,128 @@ +const decoder = new TextDecoder(); +const widths: Record = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }; +const sizes: Record = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }; + +function component(view: DataView, offset: number, type: number) { + if (type === 5120) return view.getInt8(offset); + if (type === 5121) return view.getUint8(offset); + if (type === 5122) return view.getInt16(offset, true); + if (type === 5123) return view.getUint16(offset, true); + if (type === 5125) return view.getUint32(offset, true); + if (type === 5126) return view.getFloat32(offset, true); + throw new Error("GLTF_COMPONENT"); +} + +function parse(bytes: Uint8Array) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (view.getUint32(0, true) !== 0x46546c67) + return { document: JSON.parse(decoder.decode(bytes)), binary: undefined as Uint8Array | undefined }; + let offset = 12; + let document: any; + let binary: Uint8Array | undefined; + while (offset < bytes.length) { + const length = view.getUint32(offset, true); + const type = view.getUint32(offset + 4, true); + const chunk = bytes.subarray(offset + 8, offset + 8 + length); + if (type === 0x4e4f534a) document = JSON.parse(decoder.decode(chunk).replace(/\0+$/u, "")); + if (type === 0x004e4942) binary = chunk; + offset += 8 + length; + } + if (!document) throw new Error("GLTF_JSON"); + return { document, binary }; +} + +async function load(url: string) { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP_${response.status}`); + const { document, binary } = parse(new Uint8Array(await response.arrayBuffer())); + const buffers = await Promise.all((document.buffers ?? []).map(async (buffer: any, index: number) => { + if (buffer.uri === undefined) { + if (index || !binary) throw new Error("GLTF_BUFFER"); + return binary; + } + const result = await fetch(new URL(buffer.uri, url)); + if (!result.ok) throw new Error(`HTTP_${result.status}`); + return new Uint8Array(await result.arrayBuffer()); + })); + + const accessor = (id: number, integer = false) => { + const source = document.accessors[id]; + const width = widths[source.type]; + const size = sizes[source.componentType]; + const bufferView = document.bufferViews[source.bufferView]; + const bytes = buffers[bufferView.buffer]; + const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0); + const stride = bufferView.byteStride ?? width * size; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const values = integer ? new Uint32Array(source.count * width) : new Float32Array(source.count * width); + for (let item = 0; item < source.count; item++) for (let lane = 0; lane < width; lane++) { + let value = component(view, start + item * stride + lane * size, source.componentType); + if (!integer && source.normalized) { + const maximum = source.componentType === 5121 ? 255 : source.componentType === 5123 ? 65535 : 1; + value /= maximum; + } + values[item * width + lane] = value; + } + return values; + }; + + const materials = (document.materials ?? []).map((material: any) => { + const pbr = material.pbrMetallicRoughness ?? {}; + return { + baseColor: pbr.baseColorFactor ?? [1, 1, 1, 1], + metallic: pbr.metallicFactor ?? 0, + roughness: pbr.roughnessFactor ?? 0.7, + emissive: material.emissiveFactor ?? [0, 0, 0], + alphaCutoff: material.alphaCutoff ?? 0.5, + }; + }); + const primitives: any[] = []; + const emitMesh = (meshId: number, transform: any) => { + const mesh = document.meshes?.[meshId]; + for (const primitive of mesh?.primitives ?? []) { + if ((primitive.mode ?? 4) !== 4 || primitive.attributes.POSITION === undefined) continue; + const positions = accessor(primitive.attributes.POSITION); + const indices = primitive.indices === undefined + ? Uint32Array.from({ length: positions.length / 3 }, (_, index) => index) + : accessor(primitive.indices, true); + primitives.push({ + positions, + indices, + ...(primitive.attributes.NORMAL === undefined ? {} : { normals: accessor(primitive.attributes.NORMAL) }), + ...(primitive.attributes.TANGENT === undefined ? {} : { tangents: accessor(primitive.attributes.TANGENT) }), + ...(primitive.attributes.TEXCOORD_0 === undefined ? {} : { uvs: accessor(primitive.attributes.TEXCOORD_0) }), + ...(primitive.attributes.COLOR_0 === undefined ? {} : { colors: accessor(primitive.attributes.COLOR_0) }), + material: primitive.material ?? -1, + ...transform, + }); + } + }; + + const nodes = document.nodes ?? []; + const scene = document.scenes?.[document.scene ?? 0]; + const children = new Set(nodes.flatMap((node: any) => node.children ?? [])); + const roots = scene?.nodes ?? nodes.map((_: any, id: number) => id).filter((id: number) => !children.has(id)); + const visit = (id: number, parent = { position: [0, 0, 0], scale: [1, 1, 1] }) => { + const node = nodes[id] ?? {}; + const position = (node.translation ?? [0, 0, 0]).map((value: number, lane: number) => value + parent.position[lane]); + const scale = (node.scale ?? [1, 1, 1]).map((value: number, lane: number) => value * parent.scale[lane]); + const transform = { position, scale, quaternion: node.rotation ?? [0, 0, 0, 1] }; + if (node.mesh !== undefined) emitMesh(node.mesh, transform); + for (const child of node.children ?? []) visit(child, transform); + }; + for (const root of roots) visit(root); + if (!nodes.length) for (let id = 0; id < (document.meshes ?? []).length; id++) emitMesh(id, {}); + return { materials, primitives }; +} + +addEventListener("message", async ({ data }) => { + try { + const result = await load(data.url); + const transfers = result.primitives.flatMap((primitive: any) => + ["positions", "indices", "normals", "tangents", "uvs", "colors"] + .map((name) => primitive[name]?.buffer).filter(Boolean)); + (postMessage as any)({ request: data.request, result }, transfers); + } catch (error) { + postMessage({ request: data.request, error: error instanceof Error ? error.message : "GLTF_IMPORT" }); + } +}); diff --git a/addons/handles/src/index.ts b/addons/handles/src/index.ts new file mode 100644 index 0000000..d991954 --- /dev/null +++ b/addons/handles/src/index.ts @@ -0,0 +1,29 @@ +export * from "./Scene"; +export * from "./ComputePass"; +export * from "./Node"; +export * from "./Mesh"; +export * from "./camera/Camera"; +export * from "./camera/ArcRotateCamera"; +export * from "./camera/FreeCamera"; +export * from "./camera/FollowCamera"; + +export * from "./lights/PointLight"; +export * from "./lights/RectAreaLight"; +export * from "./lights/SpotLight"; +export * from "./lights/DirectionalLight"; +export * from "./lights/AmbientLight"; + +export * from "./materials/Texture"; +export * from "./materials/PBRMaterial"; +export * from "./materials/ShaderMaterial"; + +export * from "./bvh/picking"; +export * from "./importers/gltf"; + +export * from "./PostProcesses/PostProcess"; +export * from "./PostProcesses/SSAO"; +export * from "./PostProcesses/FXAA"; +export * from "./PostProcesses/ColorGrading"; +export * from "./PostProcesses/DynamicExposure"; +export * from "./PostProcesses/Silhouette"; +export * from "./PostProcesses/Edges"; diff --git a/addons/handles/src/lights/AmbientLight.ts b/addons/handles/src/lights/AmbientLight.ts new file mode 100644 index 0000000..f213859 --- /dev/null +++ b/addons/handles/src/lights/AmbientLight.ts @@ -0,0 +1,26 @@ +import { Node, type NodeOptions } from "../Node"; +import type { Scene } from "../Scene"; + +export type AmbientLightOptions = NodeOptions & { color?: ArrayLike; intensity?: number }; + +export class AmbientLight extends Node { + constructor(scene: Scene, options: AmbientLightOptions = {}) { + super(scene, options); + const nodeReady = this.ready; + this.ready = nodeReady.then(async () => { + const array = await scene.ensureRows("ambientLights", this.id + 1, 16, "f32"); + array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 0.1]); + return this; + }); + } + + get intensity() { return this.scene.array("ambientLights").row(this.id)[3]; } + set intensity(value: number) { this.scene.array("ambientLights").row(this.id)[3] = value; } + + override async dispose() { + await this.ready; + if (this.disposed) return; + this.scene.array("ambientLights").row(this.id).fill(0); + await super.dispose(); + } +} diff --git a/addons/handles/src/lights/DirectionalLight.ts b/addons/handles/src/lights/DirectionalLight.ts new file mode 100644 index 0000000..bbbe9ef --- /dev/null +++ b/addons/handles/src/lights/DirectionalLight.ts @@ -0,0 +1,26 @@ +import { Node, type NodeOptions } from "../Node"; +import type { Scene } from "../Scene"; + +export type DirectionalLightOptions = NodeOptions & { color?: ArrayLike; intensity?: number }; + +export class DirectionalLight extends Node { + constructor(scene: Scene, options: DirectionalLightOptions = {}) { + super(scene, options); + const nodeReady = this.ready; + this.ready = nodeReady.then(async () => { + const array = await scene.ensureRows("directionalLights", this.id + 1, 32, "f32"); + array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, 1, 0, 0, 0]); + return this; + }); + } + + get intensity() { return this.scene.array("directionalLights").row(this.id)[3]; } + set intensity(value: number) { this.scene.array("directionalLights").row(this.id)[3] = value; } + + override async dispose() { + await this.ready; + if (this.disposed) return; + this.scene.array("directionalLights").row(this.id).fill(0); + await super.dispose(); + } +} diff --git a/addons/handles/src/lights/PointLight.ts b/addons/handles/src/lights/PointLight.ts new file mode 100644 index 0000000..98d363f --- /dev/null +++ b/addons/handles/src/lights/PointLight.ts @@ -0,0 +1,30 @@ +import { Node, type NodeOptions } from "../Node"; +import type { Scene } from "../Scene"; + +export type PointLightOptions = NodeOptions & { color?: ArrayLike; intensity?: number; range?: number }; + +export class PointLight extends Node { + constructor(scene: Scene, options: PointLightOptions = {}) { + super(scene, options); + const nodeReady = this.ready; + this.ready = nodeReady.then(async () => { + const array = await scene.ensureRows("pointLights", this.id + 1, 32, "f32"); + array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, options.range ?? 10, 1, 0, 0]); + return this; + }); + } + + get color() { return this.scene.array("pointLights").row(this.id).subarray(0, 3); } + set color(value: ArrayLike) { this.scene.array("pointLights").row(this.id).set(value, 0); } + get intensity() { return this.scene.array("pointLights").row(this.id)[3]; } + set intensity(value: number) { this.scene.array("pointLights").row(this.id)[3] = value; } + get range() { return this.scene.array("pointLights").row(this.id)[4]; } + set range(value: number) { this.scene.array("pointLights").row(this.id)[4] = value; } + + override async dispose() { + await this.ready; + if (this.disposed) return; + this.scene.array("pointLights").row(this.id).fill(0); + await super.dispose(); + } +} diff --git a/addons/handles/src/lights/RectAreaLight.ts b/addons/handles/src/lights/RectAreaLight.ts new file mode 100644 index 0000000..53bda1d --- /dev/null +++ b/addons/handles/src/lights/RectAreaLight.ts @@ -0,0 +1,39 @@ +import { Node, type NodeOptions } from "../Node"; +import type { Scene } from "../Scene"; + +export type RectAreaLightOptions = NodeOptions & { + color?: ArrayLike; + intensity?: number; + width?: number; + height?: number; +}; + +/** Rectangular emitter data for the default clustered forward graph's LTC path. */ +export class RectAreaLight extends Node { + readonly technique = "ltc"; + + constructor(scene: Scene, options: RectAreaLightOptions = {}) { + super(scene, options); + const nodeReady = this.ready; + this.ready = nodeReady.then(async () => { + const array = await scene.ensureRows("rectAreaLights", this.id + 1, 48, "f32"); + array.row(this.id).set([ + ...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, + options.width ?? 1, options.height ?? 1, 1, 0, + ]); + return this; + }); + } + + get width() { return this.scene.array("rectAreaLights").row(this.id)[4]; } + set width(value: number) { this.scene.array("rectAreaLights").row(this.id)[4] = value; } + get height() { return this.scene.array("rectAreaLights").row(this.id)[5]; } + set height(value: number) { this.scene.array("rectAreaLights").row(this.id)[5] = value; } + + override async dispose() { + await this.ready; + if (this.disposed) return; + this.scene.array("rectAreaLights").row(this.id).fill(0); + await super.dispose(); + } +} diff --git a/addons/handles/src/lights/SpotLight.ts b/addons/handles/src/lights/SpotLight.ts new file mode 100644 index 0000000..b3dfcba --- /dev/null +++ b/addons/handles/src/lights/SpotLight.ts @@ -0,0 +1,37 @@ +import { Node, type NodeOptions } from "../Node"; +import type { Scene } from "../Scene"; + +export type SpotLightOptions = NodeOptions & { + color?: ArrayLike; + intensity?: number; + range?: number; + innerAngle?: number; + outerAngle?: number; +}; + +export class SpotLight extends Node { + constructor(scene: Scene, options: SpotLightOptions = {}) { + super(scene, options); + const nodeReady = this.ready; + this.ready = nodeReady.then(async () => { + const array = await scene.ensureRows("spotLights", this.id + 1, 32, "f32"); + array.row(this.id).set([ + ...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, + options.range ?? 10, options.innerAngle ?? 0.35, options.outerAngle ?? 0.7, 1, + ]); + return this; + }); + } + + get innerAngle() { return this.scene.array("spotLights").row(this.id)[5]; } + set innerAngle(value: number) { this.scene.array("spotLights").row(this.id)[5] = value; } + get outerAngle() { return this.scene.array("spotLights").row(this.id)[6]; } + set outerAngle(value: number) { this.scene.array("spotLights").row(this.id)[6] = value; } + + override async dispose() { + await this.ready; + if (this.disposed) return; + this.scene.array("spotLights").row(this.id).fill(0); + await super.dispose(); + } +} diff --git a/addons/handles/src/materials/PBRMaterial.ts b/addons/handles/src/materials/PBRMaterial.ts new file mode 100644 index 0000000..3005b77 --- /dev/null +++ b/addons/handles/src/materials/PBRMaterial.ts @@ -0,0 +1,77 @@ +import type { Scene } from "../Scene"; +import type { Texture } from "./Texture"; + +export type PBRMaterialOptions = { + baseColor?: ArrayLike; + metallic?: number; + roughness?: number; + emissive?: ArrayLike; + normalScale?: number; + alphaCutoff?: number; + baseColorTexture?: Texture; + metallicRoughnessTexture?: Texture; + normalTexture?: Texture; + emissiveTexture?: Texture; +}; + +/** Conventional PBR values and texture IDs stored in two shared SOA rows. */ +export class PBRMaterial { + readonly scene: Scene; + id = -1; + readonly ready: Promise; + #disposed = false; + + constructor(scene: Scene, options: PBRMaterialOptions = {}) { + this.scene = scene; + this.ready = scene.allocateMaterial().then((id) => { + this.id = id; + const color = Array.from(options.baseColor ?? [1, 1, 1, 1]); + const emissive = Array.from(options.emissive ?? [0, 0, 0]); + if (color.length !== 4 || emissive.length !== 3) throw new TypeError("PBR material vectors"); + scene.array("materials").write(id, [ + ...color, + options.metallic ?? 0, + options.roughness ?? 0.7, + ...emissive, + options.normalScale ?? 1, + options.alphaCutoff ?? 0.5, + 0, + ]); + scene.array("materialTextures").write(id, [ + (options.baseColorTexture?.id ?? -1) + 1, + (options.metallicRoughnessTexture?.id ?? -1) + 1, + (options.normalTexture?.id ?? -1) + 1, + (options.emissiveTexture?.id ?? -1) + 1, + 0, 0, 0, 0, + ]); + return this; + }); + } + + #values() { + if (this.id < 0) throw new Error("Await material.ready before reading or writing it"); + return this.scene.array("materials").row(this.id); + } + + get baseColor() { return this.#values().subarray(0, 4); } + set baseColor(value: ArrayLike) { + if (value.length !== 4) throw new RangeError("baseColor"); + this.#values().set(value, 0); + } + get metallic() { return this.#values()[4]; } + set metallic(value: number) { this.#values()[4] = value; } + get roughness() { return this.#values()[5]; } + set roughness(value: number) { this.#values()[5] = value; } + get emissive() { return this.#values().subarray(6, 9); } + set emissive(value: ArrayLike) { + if (value.length !== 3) throw new RangeError("emissive"); + this.#values().set(value, 6); + } + + async dispose() { + await this.ready; + if (this.#disposed) return; + this.#disposed = true; + await this.scene.core.deleteObject("materials", this.id); + } +} diff --git a/addons/handles/src/materials/ShaderMaterial.ts b/addons/handles/src/materials/ShaderMaterial.ts new file mode 100644 index 0000000..65fb081 --- /dev/null +++ b/addons/handles/src/materials/ShaderMaterial.ts @@ -0,0 +1,48 @@ +import type { Scene } from "../Scene"; + +export type ShaderMaterialOptions = { + code: string; + vertexEntry?: string; + fragmentEntry?: string; +}; + +/** User WGSL represented as a material handle; registration rebuilds the Scene graph loadout. */ +export class ShaderMaterial { + readonly scene: Scene; + id = -1; + code: string; + vertexEntry: string; + fragmentEntry: string; + readonly ready: Promise; + #disposed = false; + + constructor(scene: Scene, options: ShaderMaterialOptions) { + if (!options?.code) throw new TypeError("ShaderMaterial code is required"); + this.scene = scene; + this.code = options.code; + this.vertexEntry = options.vertexEntry ?? "vertex"; + this.fragmentEntry = options.fragmentEntry ?? "fragment"; + this.ready = scene.allocateMaterial().then(async (id) => { + this.id = id; + await scene.registerShader(this); + return this; + }); + } + + async update(options: Partial) { + await this.ready; + if (options.code !== undefined) this.code = options.code; + if (options.vertexEntry !== undefined) this.vertexEntry = options.vertexEntry; + if (options.fragmentEntry !== undefined) this.fragmentEntry = options.fragmentEntry; + await this.scene.registerShader(this); + return this; + } + + async dispose() { + await this.ready; + if (this.#disposed) return; + this.#disposed = true; + await this.scene.unregisterShader(this.id); + await this.scene.core.deleteObject("materials", this.id); + } +} diff --git a/addons/handles/src/materials/Texture.ts b/addons/handles/src/materials/Texture.ts new file mode 100644 index 0000000..cb2850e --- /dev/null +++ b/addons/handles/src/materials/Texture.ts @@ -0,0 +1,44 @@ +import type { Scene } from "../Scene"; + +export type TextureOptions = { + source?: string | ImageBitmap; + size?: [number | "canvas", number | "canvas", number?]; + format?: string; + usage?: string[]; + transient?: boolean; +}; + +let nextTextureName = 1; + +/** A graph texture resource handle; image decoding/upload policy remains outside core. */ +export class Texture { + readonly scene: Scene; + readonly id: number; + readonly resource: string; + readonly source?: string | ImageBitmap; + readonly ready: Promise; + #disposed = false; + + constructor(scene: Scene, options: TextureOptions = {}) { + this.scene = scene; + this.resource = `texture-${nextTextureName++}`; + this.source = options.source; + const registration = scene.registerTexture({ + id: this.resource, + source: options.source, + size: options.size ?? [1, 1, 1], + format: options.format ?? "rgba8unorm", + usage: [...new Set([...(options.usage ?? ["copyDst"]), "sampled"])], + transient: options.transient ?? false, + }); + this.id = registration.number; + this.ready = registration.ready; + } + + async dispose() { + await this.ready; + if (this.#disposed) return; + this.#disposed = true; + await this.scene.unregisterTexture(this.id); + } +} diff --git a/addons/mesh-handles/package.json b/addons/mesh-handles/package.json deleted file mode 100644 index e7c25d3..0000000 --- a/addons/mesh-handles/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@yawn/mesh-handles", - "version": "0.1.0", - "description": "Conventional mesh, instance, camera, and material handles over Yawn render data", - "type": "module", - "exports": "./src/index.js" -} diff --git a/addons/mesh-handles/src/index.js b/addons/mesh-handles/src/index.js deleted file mode 100644 index c287bec..0000000 --- a/addons/mesh-handles/src/index.js +++ /dev/null @@ -1,55 +0,0 @@ -const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; - -class Handle { - constructor(array, row = 0) { - this.array = array; - this.row = row; - } - get state() { return this.array.read(this.row); } - set state(value) { this.array.write(this.row, value); } - patch(offset, values) { - const state = this.state; - state.splice(offset, values.length, ...values); - this.state = state; - } -} - -/** Conventional camera values over one caller-owned shared row. */ -export class CameraHandle extends Handle { - static async create(core, name = "camera") { - const array = await core.allocateRows({ name, rows: 1, stride: 64, format: "f32" }); - const camera = new CameraHandle(array); - camera.state = [0, 0, 4, 0, 0, 0, 0, 0, 0, 1, 0, 0, Math.PI / 3, 1, 0.1, 1000]; - return camera; - } - get position() { return this.state.slice(0, 3); } - set position(value) { this.patch(0, value); } - get target() { return this.state.slice(4, 7); } - set target(value) { this.patch(4, value); } -} - -/** Conventional material properties over one eight-float shared row. */ -export class MaterialHandle extends Handle { - static async create(core, name = "material") { - const material = new MaterialHandle(await core.allocateRows({ name, rows: 1, stride: 32, format: "f32" })); - material.state = [1, 1, 1, 1, 0, 1, 0, 0]; - return material; - } - get baseColor() { return this.state.slice(0, 4); } - set baseColor(value) { this.patch(0, value); } - get metallic() { return this.state[4]; } - set metallic(value) { this.patch(4, [value]); } - get roughness() { return this.state[5]; } - set roughness(value) { this.patch(5, [value]); } -} - -/** Conventional mesh transform over one SIMD-aligned shared row. */ -export class MeshHandle extends Handle { - static async create(core, name = "mesh") { - const mesh = new MeshHandle(await core.allocateRows({ name, rows: 1, stride: 64, format: "f32" })); - mesh.transform = identity; - return mesh; - } - get transform() { return this.state; } - set transform(value) { this.state = value; } -} diff --git a/addons/render-graph-ast/package.json b/addons/render-graph-ast/package.json deleted file mode 100644 index 6742f9f..0000000 --- a/addons/render-graph-ast/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@yawn/render-graph-ast", - "version": "0.1.0", - "description": "Canonical Yawn render-graph AST and S-expression codec", - "type": "module", - "exports": "./src/index.js" -} diff --git a/addons/render-graph-ast/src/index.js b/addons/render-graph-ast/src/index.js deleted file mode 100644 index 5fc6f90..0000000 --- a/addons/render-graph-ast/src/index.js +++ /dev/null @@ -1,34 +0,0 @@ -export const GRAPH_AST_VERSION = 1; - -const data = value => value === null || typeof value === "string" || typeof value === "boolean" || - (typeof value === "number" && Number.isFinite(value)) || - (Array.isArray(value) && value.every(data)) || - (value?.constructor === Object && Object.values(value).every(data)); - -function freeze(value) { - if (value && typeof value === "object") { - Object.values(value).forEach(freeze); - Object.freeze(value); - } - return value; -} - -/** Creates the data-only AST consumed by every graph frontend. DAG edges are pass `after` IDs. */ -export function createGraphAst(graph) { - if (!data(graph) || graph?.constructor !== Object || typeof graph.id !== "string" || - !Array.isArray(graph.passes)) throw new TypeError("GRAPH_AST"); - return freeze(structuredClone(graph)); -} - -function encode(value) { - 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("")})`; - return `(object${Object.keys(value).sort().map(key => - ` (field ${JSON.stringify(key)} ${encode(value[key])})`).join("")})`; -} - -/** Serializes the AST as an S-expression; named pass references preserve DAG fan-out. */ -export function serializeGraphAst(graph) { - return `(yawn-graph ${GRAPH_AST_VERSION} ${encode(createGraphAst(graph))})`; -} diff --git a/addons/render-graph-fxnode/package.json b/addons/render-graph-fxnode/package.json deleted file mode 100644 index d6175f4..0000000 --- a/addons/render-graph-fxnode/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@yawn/render-graph-fxnode", - "version": "0.1.0", - "description": "FXNode frontend for Yawn render graphs", - "type": "module", - "exports": "./src/index.js", - "dependencies": { - "@yawn/render-graph-ast": "0.1.0" - } -} diff --git a/addons/render-graph-fxnode/src/index.js b/addons/render-graph-fxnode/src/index.js deleted file mode 100644 index 672d791..0000000 --- a/addons/render-graph-fxnode/src/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import { createGraphAst } from "@yawn/render-graph-ast"; - -/** Exports FXNode nodes with a `pass` payload and links as the canonical pass DAG. */ -export function adaptFxNodeSnapshot(snapshot, { pipelines = {}, resources = {} } = {}) { - if (!Array.isArray(snapshot?.nodes) || !Array.isArray(snapshot?.links)) throw new TypeError("FXNODE_GRAPH"); - const passes = snapshot.nodes.map(node => { - if (!node?.id || !node.pass) throw new TypeError("FXNODE_PASS"); - return { ...structuredClone(node.pass), id: node.id, after: [...(node.pass.after ?? [])] }; - }); - const byId = new Map(passes.map(pass => [pass.id, pass])); - for (const link of snapshot.links) { - const source = link.fromNodeId ?? link.from?.node; - const target = link.toNodeId ?? link.to?.node; - if (!byId.has(source) || !byId.has(target)) throw new TypeError("FXNODE_LINK"); - if (!byId.get(target).after.includes(source)) byId.get(target).after.push(source); - } - return createGraphAst({ id: snapshot.graphId ?? "fxnode", pipelines, resources, passes }); -} diff --git a/addons/render-graph-js/package.json b/addons/render-graph-js/package.json deleted file mode 100644 index c7c7979..0000000 --- a/addons/render-graph-js/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@yawn/render-graph-js", - "version": "0.1.0", - "description": "JSO and builder frontend for Yawn render graphs", - "type": "module", - "exports": "./src/index.js", - "dependencies": { - "@yawn/render-graph-ast": "0.1.0" - } -} diff --git a/addons/render-graph-js/src/index.js b/addons/render-graph-js/src/index.js deleted file mode 100644 index 9e092fe..0000000 --- a/addons/render-graph-js/src/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import { createGraphAst, serializeGraphAst } from "@yawn/render-graph-ast"; - -/** Converts a plain JavaScript object into the canonical render-graph AST. */ -export const graphFromObject = graph => createGraphAst(graph); - -/** Serializes a JSO graph and asks Yawn Core to prepare and activate its loadout. */ -export function loadGraph(core, graph) { - if (!core?.loadGraph) throw new TypeError("core must be a YawnCore instance"); - return core.loadGraph(serializeGraphAst(graphFromObject(graph))); -} - -export { serializeGraphAst }; diff --git a/core/index.js b/core/index.js index a0b5832..66fda8c 100644 --- a/core/index.js +++ b/core/index.js @@ -77,6 +77,18 @@ export class YawnCore { return array; } + async createRowsBatch(rows) { + await this.ready; + if (!Array.isArray(rows) || !rows.length) throw new TypeError("ROWS"); + const descriptors = await this.#request("create-rows-batch", { rows }); + return descriptors.map(descriptor => { + const array = this.#arrays.get(descriptor.name)?.update(descriptor) + ?? new SharedRows(this.#buffer, descriptor); + this.#arrays.set(descriptor.name, array); + return array; + }); + } + async deleteRows(name) { await this.ready; await this.#request("delete-rows", { name }); diff --git a/core/lib.rs b/core/lib.rs index 14db868..dd2cf7e 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -1,6 +1,7 @@ use std::cell::RefCell; use std::rc::Rc; +use serde::Deserialize; use wasm_bindgen::prelude::*; #[path = "render_graph/compiler.rs"] @@ -22,6 +23,14 @@ use render::RenderLoop; use render_data::RenderData; use store::Store; +#[derive(Deserialize)] +struct RowRequest { + name: String, + rows: u32, + stride: u32, + format: String, +} + #[wasm_bindgen] pub struct Core { data: Rc>, @@ -79,6 +88,36 @@ impl Core { Ok(serde_json::to_string(&rows).unwrap()) } + pub fn create_rows_batch(&self, source: &str) -> Result { + let requests: Vec = + serde_json::from_str(source).map_err(|_| JsError::new("ROWS"))?; + if requests.is_empty() { + return Err(JsError::new("ROWS")); + } + let refresh = requests + .iter() + .any(|request| self.store.borrow().uses_rows(&request.name)); + let descriptors = { + let mut data = self.data.borrow_mut(); + requests + .into_iter() + .map(|request| { + data.create_rows(request.name, request.rows, request.stride, request.format) + .map_err(JsError::new) + }) + .collect::, _>>()? + }; + if refresh { + if let Some(gpu) = self.gpu.borrow().as_ref() { + self.store + .borrow_mut() + .refresh(gpu, &self.data.borrow()) + .map_err(|error| JsError::new(&error))?; + } + } + Ok(serde_json::to_string(&descriptors).unwrap()) + } + pub fn delete_rows(&self, name: String) -> Result<(), JsError> { if self.store.borrow().uses_rows(&name) { return Err(JsError::new("ROWS_ACTIVE")); diff --git a/core/render_graph/store.rs b/core/render_graph/store.rs index 2ef29f4..3a74a93 100644 --- a/core/render_graph/store.rs +++ b/core/render_graph/store.rs @@ -54,7 +54,14 @@ impl Store { if !self.uses_rows(name) { return Ok(()); } - let graph = self.active.as_ref().unwrap().graph.clone(); + self.refresh(gpu, data) + } + + pub fn refresh(&mut self, gpu: &Wgpu, data: &RenderData) -> Result<(), String> { + let Some(active) = self.active.as_ref() else { + return Ok(()); + }; + let graph = active.graph.clone(); let resources = GpuResources::activate(&graph, gpu, data)?; self.active = Some(Loadout { graph, resources }); Ok(()) diff --git a/core/worker.js b/core/worker.js index 50a9f13..c64d7ce 100644 --- a/core/worker.js +++ b/core/worker.js @@ -21,6 +21,9 @@ addEventListener("message", async ({ data: message }) => { case "create-rows": result = JSON.parse(core.create_rows(message.name, message.rows, message.stride, message.format)); break; + case "create-rows-batch": + result = JSON.parse(core.create_rows_batch(JSON.stringify(message.rows))); + break; case "delete-rows": core.delete_rows(message.name); break; diff --git a/docs/.vitepress/Playground.vue b/docs/.vitepress/Playground.vue index dc91132..2d7bf94 100644 --- a/docs/.vitepress/Playground.vue +++ b/docs/.vitepress/Playground.vue @@ -1,19 +1,26 @@ diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index a336c8b..d89f50f 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -23,9 +23,29 @@ export default defineConfig({ cleanUrls: true, themeConfig: { nav: [ - { text: "Architecture", link: "/" }, + { text: "Guide", link: "/guide/getting-started" }, + { text: "Core", link: "/guide/core" }, { text: "Playground", link: "/playground" }, ], + sidebar: { + "/guide/": [ + { + text: "Learn Yawn", + items: [ + { text: "Getting started", link: "/guide/getting-started" }, + { text: "Scene and shared data", link: "/guide/scene-and-sab" }, + { text: "Cameras and controls", link: "/guide/cameras" }, + { text: "Meshes and instances", link: "/guide/meshes-and-instances" }, + { text: "Materials and textures", link: "/guide/materials" }, + { text: "Clustered lights", link: "/guide/lights" }, + { text: "Compute passes", link: "/guide/compute" }, + { text: "Post processing", link: "/guide/post-processing" }, + { text: "glTF and picking", link: "/guide/importing-and-picking" }, + { text: "Core boundary", link: "/guide/core" }, + ], + }, + ], + }, }, vite: { plugins: [isolation], diff --git a/docs/guide/cameras.md b/docs/guide/cameras.md new file mode 100644 index 0000000..2dce2ef --- /dev/null +++ b/docs/guide/cameras.md @@ -0,0 +1,75 @@ +# Cameras and controls + +Every camera allocates a generic `cameras` slot and a transform node. Projection, lens, controller state, and transforms are direct SAB rows after construction. + +## Shared lens and projection controls + +```ts +const camera = new Camera(scene, { + fov: Math.PI / 3, + near: 0.05, + far: 2000, + focalLength: 50, + aperture: 2.8, + focusDistance: 8, +}); +await camera.ready; + +camera.projection = "orthographic"; +camera.orthoSize = 12; +camera.projection = "perspective"; +``` + +`fov`, `aspect`, `near`, `far`, `orthoSize`, `focalLength`, `aperture`, `focusDistance`, and `sensorWidth` all write the camera's shared row. + +## Arc rotate + +```ts +const orbit = new ArcRotateCamera(scene, { + alpha: 0, + beta: Math.PI / 3, + radius: 6, + target: mesh, + controls: { + element: canvas, + pointer: true, // left orbit, right pan, wheel zoom + controller: true, // sticks + triggers + }, +}); +await orbit.ready; +``` + +## Free spectator camera + +```ts +const free = new FreeCamera(scene, { + position: [0, 1, 5], + controls: { + element: canvas, + keyboard: true, // WASD + Space/Ctrl + pointer: true, // click for pointer lock, mouse to look + controller: true, + speed: 6, + }, +}); +await free.ready; +``` + +## Follow a character + +```ts +const follow = new FollowCamera(scene, { + target: player, + distance: 5, + height: 1.8, + smoothing: 0.12, +}); +await follow.ready; + +follow.target = anotherPlayer; +follow.distance = 7; +follow.stop(); +follow.start(); +``` + +The input and follow loops never post camera updates to core: they read and mutate the same camera, position, and quaternion rows that any other worker can use. diff --git a/docs/guide/compute.md b/docs/guide/compute.md new file mode 100644 index 0000000..a1004b9 --- /dev/null +++ b/docs/guide/compute.md @@ -0,0 +1,42 @@ +# Compute passes + +Compute shaders are graph data, not core code. Declare the shared rows/resources a shader needs and attach the pass to `Scene`. + +```ts +const velocity = await scene.ensureRows("velocity", 4096, 16, "f32"); + +const simulation = new ComputePass({ + id: "integrate", + code: ` + @group(0) @binding(0) + var velocity: array>; + + @compute @workgroup_size(64) + fn main(@builtin(global_invocation_id) id: vec3) { + if (id.x < arrayLength(&velocity)) { + velocity[id.x].y -= 0.001; + } + } + `, + buffers: [{ id: "velocity", array: "velocity", usage: ["storage"] }], + bindings: [{ group: 0, binding: 0, resource: "velocity" }], + dispatch: [64, 1, 1], +}); + +await scene.addComputePass(simulation); +``` + + + +The graph DAG places an unqualified custom compute pass after light clustering and makes forward rendering depend on all custom compute passes. Use `after` to specify other dependencies. + +```ts +await simulation.update({ dispatch: [128, 1, 1] }); +await scene.removeComputePass(simulation); +``` + +Both operations are infrequent graph/loadout messages. Existing source row changes are direct SAB writes. + + diff --git a/docs/guide/core.md b/docs/guide/core.md new file mode 100644 index 0000000..092117b --- /dev/null +++ b/docs/guide/core.md @@ -0,0 +1,44 @@ +# Core boundary + +`@yawn/core` deliberately contains no scene types and no WGSL. It owns two things: + +1. a 64-byte-aligned arena of named f32/u32/i32 SOA rows in one `SharedArrayBuffer`; +2. render-graph compilation, up-front WebGPU loadouts, transient resource aliasing, render bundles, and the paced render loop. + +```text +infrequent worker messages hot shared mutations +┌──────────────────────────────┐ ┌──────────────────────┐ +│ create/delete rows │ │ transforms │ +│ allocate/delete object slot │ │ cameras/materials │ +│ compile/switch graph │ │ lights/app data │ +│ play/pause/set FPS │ │ info.skipRender │ +└──────────────┬───────────────┘ └──────────┬───────────┘ + └─────────────────┬─────────────────────┘ + ▼ + ┌─────────────────┐ + │ Rust/WASM core │ + └─────────────────┘ +``` + +## Use core directly + +```ts +import { YawnCore } from "@yawn/core"; + +const core = new YawnCore(canvas, { arenaBytes: 64 * 1024 * 1024 }); +await core.ready; + +const values = await core.createRows({ + name: "application.values", + rows: 1024, + stride: 16, + format: "f32", +}); + +const id = await core.allocateObject("application.values"); +values.row(id).set([1, 2, 3, 4]); +``` + +Graph frontends serialize plain data to `(yawn-graph 1 ...)`. Named `after` edges preserve DAG fan-out; Rust sorts passes, detects cycles, culls unused declarations, plans compatible transient lifetimes, and allocates the active loadout. + +The `Scene` addon is one such frontend. It is replaceable and has no privileged core API. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md new file mode 100644 index 0000000..8df1ae7 --- /dev/null +++ b/docs/guide/getting-started.md @@ -0,0 +1,57 @@ +# Getting started + +Yawn separates the data/render engine from optional scene conventions. Most applications begin with `@yawn/handles`; specialized engines can use `@yawn/core` directly. + +## 1. Serve with isolation headers + +`SharedArrayBuffer` requires `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`. The included VitePress server already sends both. + +```html + + +``` + +## 2. Start a Scene + +```ts +import { Scene } from "@yawn/handles"; + +const canvas = document.querySelector("#view")!; +canvas.width = 1280; +canvas.height = 720; + +const scene = new Scene(canvas, { hdr: true, fps: 60 }); +await scene.ready; +``` + +`Scene` initializes conventional SOA rows and loads one clustered-forward HDR render graph. The core itself still starts with only its eight-float `info` row. + +## 3. Add a triangle + +```ts +import { Mesh, PBRMaterial } from "@yawn/handles"; + +const blue = new PBRMaterial(scene, { + baseColor: [0.15, 0.55, 1, 1], + metallic: 0.15, + roughness: 0.4, +}); +await blue.ready; + +const triangle = new Mesh(scene, { + material: blue, + vertexData: { + positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0], + indices: [0, 1, 2], + }, +}); +await triangle.ready; +``` + +Constructors use worker messages only to reserve slots or rebuild the graph. Once `ready` resolves, ordinary property writes mutate shared memory. + + + + diff --git a/docs/guide/importing-and-picking.md b/docs/guide/importing-and-picking.md new file mode 100644 index 0000000..7262f2d --- /dev/null +++ b/docs/guide/importing-and-picking.md @@ -0,0 +1,35 @@ +# glTF import and picking + +## Import off-thread + +The shared importer worker fetches and parses glTF/GLB, then the response hydrates `Mesh` and `PBRMaterial` handles attached to your scene. + +```ts +import { importGltf } from "@yawn/handles"; + +const meshes = await importGltf(scene, "/models/helmet.glb"); +meshes[0].position[1] = 0.5; +``` + +The importer handles triangle primitives, external/data buffers, standard vertex attributes, indices, node transforms, and metallic-roughness values. Application-specific extensions remain application policy. + +## Pick in the BVH worker + +```ts +const picking = new Picking(scene); +await picking.ready; + +canvas.addEventListener("click", async () => { + const hits = await picking.pick([0, 0, 4], [0, 0, -1]); + const nearest = hits[0]; + if (nearest) console.log(nearest.id, nearest.distance); +}); +``` + +The worker reads shared node positions and mesh bounds, updates its BVH when the shared frame counter changes, and returns **all** AABB hits sorted by distance. You can shortlist or run an exact test afterward. + +If row allocations relocated since `Picking` was created, refresh its shared descriptors: + +```ts +await picking.refresh(); +``` diff --git a/docs/guide/lights.md b/docs/guide/lights.md new file mode 100644 index 0000000..1908661 --- /dev/null +++ b/docs/guide/lights.md @@ -0,0 +1,47 @@ +# Clustered lights + +The default `Scene` graph runs a clustered compute pass before its HDR forward passes. Light handles write flat rows consumed by that pass. + +```ts +const point = new PointLight(scene, { + position: [0, 1, 1], + color: [1, 0.25, 0.05], + intensity: 20, + range: 8, +}); + +const sun = new DirectionalLight(scene, { + quaternion: [0.2, 0, 0, 0.98], + color: [1, 0.95, 0.8], + intensity: 3, +}); + +const fill = new AmbientLight(scene, { color: [0.1, 0.2, 0.4], intensity: 0.2 }); +await Promise.all([point.ready, sun.ready, fill.ready]); +``` + + + +## Rectangles and spots + +```ts +const panel = new RectAreaLight(scene, { + position: [0, 2, 0], + width: 2, + height: 0.5, + intensity: 12, +}); + +const spot = new SpotLight(scene, { + position: [0, 1, 1], + innerAngle: 0.25, + outerAngle: 0.6, + range: 15, +}); +``` + +The rectangle handle selects the default graph's linearly transformed cosine (`ltc`) path. Position, orientation, intensity, angles, and colors remain direct SAB mutations after allocation. + + diff --git a/docs/guide/materials.md b/docs/guide/materials.md new file mode 100644 index 0000000..1578255 --- /dev/null +++ b/docs/guide/materials.md @@ -0,0 +1,59 @@ +# Materials and textures + +`PBRMaterial` is a conventional view over `materials` and `materialTextures` SOA rows. + +```ts +const paint = new PBRMaterial(scene, { + baseColor: [0.8, 0.05, 0.03, 1], + metallic: 0.65, + roughness: 0.22, + emissive: [0, 0, 0], +}); +await paint.ready; + +paint.roughness = 0.5; // direct SAB write +paint.baseColor[1] = 0.35; // direct SAB write +mesh.material = paint; +``` + + + +## Graph textures + +```ts +const albedo = new Texture(scene, { + source: "/textures/paint.ktx2", + size: [2048, 2048, 1], + format: "rgba8unorm-srgb", + usage: ["sampled", "copyDst"], +}); +await albedo.ready; + +const textured = new PBRMaterial(scene, { baseColorTexture: albedo }); +``` + +Creating or removing a `Texture` rebuilds the single graph loadout so the GPU resource is allocated up front. The source pointer stays on the handle for an importer or application uploader; core never owns image-loading policy. + +## Custom WGSL + +`ShaderMaterial` adds its external WGSL pipeline to the scene graph. Updating the code updates the loadout. + +```ts +const shader = new ShaderMaterial(scene, { + code: ` + struct Out { @builtin(position) position: vec4 } + @vertex fn vertex(@builtin(vertex_index) id: u32) -> Out { + let p = array(vec2(-.2, -.2), vec2(.2, -.2), vec2(0., .2)); + var out: Out; out.position = vec4(p[id], 0., 1.); return out; + } + @fragment fn fragment() -> @location(0) vec4 { + return vec4(1., .2, .7, 1.); + } + `, +}); +await shader.ready; +``` + + diff --git a/docs/guide/meshes-and-instances.md b/docs/guide/meshes-and-instances.md new file mode 100644 index 0000000..269c9f5 --- /dev/null +++ b/docs/guide/meshes-and-instances.md @@ -0,0 +1,49 @@ +# Meshes and instances + +Every `Mesh` is an instance. `clone()` shares geometry and allocates only a new node/mesh slot. + +```ts +const source = new Mesh(scene, { + vertexData: { + positions: [-0.2, -0.2, 0, 0.2, -0.2, 0, 0, 0.25, 0], + indices: [0, 1, 2], + }, +}); +await source.ready; + +for (let x = -4; x <= 4; x++) { + const instance = source.clone({ position: [x * 0.2, 0, 0] }); + await instance.ready; +} +``` + + + +## Copy-on-write geometry + +The default vertex kinds are `positions`, `normals`, `tangents`, `uvs`, `colors`, and `indices`. Mutating any kind on an instanced clone first makes that mesh's geometry unique. + +```ts +const clone = source.clone(); +await clone.ready; + +await clone.setVertexData("positions", [ + -0.4, -0.2, 0, + 0.4, -0.2, 0, + 0.0, 0.5, 0, +]); +``` + +## Per-face materials and visibility + +```ts +await mesh.setMaterialForFaces(red, [0, 2, 4]); +mesh.material = blue; +mesh.isVisible = false; // one direct u32 write +``` + +Face rows store optional material pointers; a zero lane falls back to `mesh.material`. + + diff --git a/docs/guide/post-processing.md b/docs/guide/post-processing.md new file mode 100644 index 0000000..eb59443 --- /dev/null +++ b/docs/guide/post-processing.md @@ -0,0 +1,36 @@ +# Post processing + +Post-process handles insert or remove graph passes. Intermediate HDR textures are transient and compatible non-overlapping lifetimes alias the same physical allocation. + +```ts +const ssao = new SSAO(scene, { amount: 0.8 }); +const exposure = new DynamicExposure(scene, { exposure: 1.1 }); +const grade = new ColorGrading(scene, { toneMap: "aces", amount: 1.0 }); +const fxaa = new FXAA(scene); + +await Promise.all([ssao.ready, exposure.ready, grade.ready, fxaa.ready]); +``` + + + +Every effect is optional: + +```ts +await ssao.setEnabled(false); +await grade.update({ toneMap: "reinhard" }); +await fxaa.dispose(); +``` + +Also available: + +```ts +const outline = new Silhouette(scene, { amount: 1 }); +const edgeImage = new Edges(scene, { amount: 2, enabled: false }); +await edgeImage.setEnabled(true); +``` + +The final present pass tone-maps HDR to the canvas. `ColorGrading` supports `aces`, `reinhard`, and `linear` tone maps. + + diff --git a/docs/guide/scene-and-sab.md b/docs/guide/scene-and-sab.md new file mode 100644 index 0000000..00ea2cc --- /dev/null +++ b/docs/guide/scene-and-sab.md @@ -0,0 +1,44 @@ +# Scene and shared data + +Think of every handle as an array index, not an object mirrored into core. `Node.position`, `Node.quaternion`, and `Node.scale` are views into separate flat SOA arrays. + +## Direct transform movement + +```ts +import { Node } from "@yawn/handles"; + +const pivot = new Node(scene, { position: [0, 1, 0] }); +await pivot.ready; + +canvas.addEventListener("pointermove", (event) => { + pivot.position[0] += event.movementX * 0.002; + pivot.position[1] -= event.movementY * 0.002; +}); +``` + +The pointer handler sends no messages. The typed-array view points directly into the arena shared with the render worker. The camera helpers use this same pattern; see [Cameras and controls](/guide/cameras). + +## Add an application-specific row + +```ts +const particles = await scene.ensureRows("particleVelocity", 10_000, 16, "f32"); +particles.row(42).set([1, 0, 0, 0]); +``` + +Rows are 16-byte-stride-aligned and arena allocations are 64-byte aligned. Formats are `f32`, `u32`, or `i32`. + +## Timing and render skipping + +Core always creates `info` as: + +```text +[deltaTime, frameCount, elapsedTime, targetFps, skipRender, 0, 0, 0] +``` + +```ts +const info = scene.array("info").row(0); +info[4] = 1; // keep timing, skip GPU work +info[4] = 0; // resume rendering +``` + +Use messages for rare control changes (`setFps`, graph updates, allocation); use SAB writes for existing hot state. diff --git a/docs/index.md b/docs/index.md index a1df226..f3b922f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,41 +2,49 @@ layout: home hero: name: Yawn - text: Shared render data and a render graph. - tagline: One Rust/WASM core, one fixed arena, no built-in scene model or shader. + text: Render graphs over shared data. + tagline: A small Rust/WASM core with optional conventional TypeScript handles. actions: - theme: brand - text: Open the playground + text: Start the tutorial + link: /guide/getting-started + - theme: alt + text: Open playground link: /playground features: - - title: Shared rows - details: Allocate an SOA row array once by message, then mutate its SAB views directly from any thread. - - title: External graphs - details: JSO and FXNode addons serialize DAGs to the S-expression AST consumed by the worker. - - title: Up-front loadouts - details: Pipelines, GPU resources, pass order, and compatible transient aliases are prepared before activation. + - title: Hot state stays shared + details: Transform, material, light, and camera changes are direct SharedArrayBuffer writes from any thread. + - title: Graph-authored GPU work + details: WGSL, pipelines, compute, HDR, and post effects live in one externally supplied DAG loadout. + - title: Conventional when wanted + details: The handles addon supplies Scene, Mesh, materials, lights, glTF import, and BVH picking without adding core semantics. --- -## The entire boundary +## The shortest useful scene -```js -const color = await core.allocateRows({ - name: "triangle.color", - rows: 1, - stride: 16, - format: "f32", +```ts +import { Mesh, PBRMaterial, Scene } from "@yawn/handles"; + +const scene = new Scene(document.querySelector("canvas"), { hdr: true }); +await scene.ready; + +const material = new PBRMaterial(scene, { baseColor: [0.2, 0.7, 1, 1] }); +await material.ready; +const mesh = new Mesh(scene, { + material, + vertexData: { + positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.7, 0], + indices: [0, 1, 2], + }, }); +await mesh.ready; -color.write(0, [0.2, 0.65, 1, 1]); -color.row(0)[0] = 0.8; // direct SharedArrayBuffer write -await loadGraph(core, graph); // infrequent message +mesh.position[0] = 0.25; // direct SAB mutation ``` -`@yawn/core` contains only the Rust/WASM render-data arena and graph compiler plus the browser worker required to execute WebGPU. Rust owns the fixed 64-byte-aligned shared arena, DAG ordering, resource culling, and transient texture planning; the worker materializes the resulting loadout. Every scene convention and every byte of WGSL comes from an addon or application. +`Scene` installs one HDR clustered-forward loadout. Adding compute, custom shaders, textures, or post effects rebuilds that same loadout; changing values already present in shared rows does not send a message. ```text -JSO / FXNode ──▶ AST ──▶ S-expression ──▶ Rust/WASM ──▶ worker ──▶ WebGPU -any JS thread ────────────── direct SAB row writes ────────────────────┘ +handles ──▶ graph AST ──▶ S-expression ──▶ core worker ──▶ Rust/WebGPU +any JS thread ─────────────── direct SAB row writes ────────────────┘ ``` - -The addon packages provide graph serialization, optional WGSL, glTF import directly into shared rows, and conventional camera/material/mesh handles. None of them add semantics to core. diff --git a/docs/playground.md b/docs/playground.md index 9998c47..719f00d 100644 --- a/docs/playground.md +++ b/docs/playground.md @@ -1,6 +1,6 @@ # Minimal playground -This is the one runnable example. It allocates a single `f32` row, sends an externally authored JSO render graph through the AST codec, and changes color by writing the shared row directly on pointer movement. +This page creates an HDR `Scene`, one PBR material, and one indexed mesh. Move the pointer over the canvas: the handler writes the `sceneAccent` shared row directly without messaging the renderer. diff --git a/package-lock.json b/package-lock.json index 1351602..8ea3155 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,34 +15,11 @@ "vitepress": "^1.6.4" } }, - "addons/default-pipelines": { - "name": "@yawn/default-pipelines", - "version": "0.1.0" - }, - "addons/gltf-import": { - "name": "@yawn/gltf-import", - "version": "0.1.0" - }, - "addons/mesh-handles": { - "name": "@yawn/mesh-handles", - "version": "0.1.0" - }, - "addons/render-graph-ast": { - "name": "@yawn/render-graph-ast", - "version": "0.1.0" - }, - "addons/render-graph-fxnode": { - "name": "@yawn/render-graph-fxnode", + "addons/handles": { + "name": "@yawn/handles", "version": "0.1.0", "dependencies": { - "@yawn/render-graph-ast": "0.1.0" - } - }, - "addons/render-graph-js": { - "name": "@yawn/render-graph-js", - "version": "0.1.0", - "dependencies": { - "@yawn/render-graph-ast": "0.1.0" + "@yawn/core": "0.1.0" } }, "core": { @@ -1245,28 +1222,8 @@ "resolved": "core", "link": true }, - "node_modules/@yawn/default-pipelines": { - "resolved": "addons/default-pipelines", - "link": true - }, - "node_modules/@yawn/gltf-import": { - "resolved": "addons/gltf-import", - "link": true - }, - "node_modules/@yawn/mesh-handles": { - "resolved": "addons/mesh-handles", - "link": true - }, - "node_modules/@yawn/render-graph-ast": { - "resolved": "addons/render-graph-ast", - "link": true - }, - "node_modules/@yawn/render-graph-fxnode": { - "resolved": "addons/render-graph-fxnode", - "link": true - }, - "node_modules/@yawn/render-graph-js": { - "resolved": "addons/render-graph-js", + "node_modules/@yawn/handles": { + "resolved": "addons/handles", "link": true }, "node_modules/algoliasearch": {