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 <heaust.azure@gmail.com>
This commit is contained in:
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
const triangleShader = /* wgsl */ `
|
|
||||||
struct Tint { color: vec4<f32> }
|
|
||||||
@group(0) @binding(0) var<uniform> tint: Tint;
|
|
||||||
|
|
||||||
struct Vertex { @builtin(position) position: vec4<f32> }
|
|
||||||
|
|
||||||
@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<f32> { 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 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Omit<ComputePassOptions, "id">>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Record<VertexKind, ArrayLike<number>>>;
|
||||||
|
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<number, number>();
|
||||||
|
#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<MeshOptions, "geometryId" | "vertexData"> = {}) {
|
||||||
|
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<number>) {
|
||||||
|
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<number>, 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
|
export type NodeOptions = {
|
||||||
|
position?: ArrayLike<number>;
|
||||||
|
quaternion?: ArrayLike<number>;
|
||||||
|
scale?: ArrayLike<number>;
|
||||||
|
parent?: Node;
|
||||||
|
};
|
||||||
|
|
||||||
|
function vector(value: ArrayLike<number>, 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<this>;
|
||||||
|
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<number>) { this.#row("nodePositions").set(vector(value, 3, "position")); }
|
||||||
|
|
||||||
|
get quaternion(): Float32Array { return this.#row("nodeQuaternions").subarray(0, 4); }
|
||||||
|
set quaternion(value: ArrayLike<number>) { this.#row("nodeQuaternions").set(vector(value, 4, "quaternion")); }
|
||||||
|
|
||||||
|
get scale(): Float32Array { return this.#row("nodeScales").subarray(0, 3); }
|
||||||
|
set scale(value: ArrayLike<number>) { 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
enabled: boolean;
|
||||||
|
ready: Promise<void>;
|
||||||
|
|
||||||
|
protected constructor(scene: Scene, kind: string, options: Record<string, unknown> = {}) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
this.options = { ...this.options, ...options };
|
||||||
|
this.ready = this.scene.setPostProcess(this, this.enabled);
|
||||||
|
return this.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() { return this.setEnabled(false); }
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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<number, number>;
|
||||||
|
};
|
||||||
|
type ShaderLike = {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
vertexEntry: string;
|
||||||
|
fragmentEntry: string;
|
||||||
|
};
|
||||||
|
type PostProcessState = { id: string; kind: string; options: Record<string, unknown> };
|
||||||
|
type TextureState = GraphTexture & { number: number; source?: string | ImageBitmap };
|
||||||
|
|
||||||
|
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<storage, read> pointLights: array<vec4<f32>>;
|
||||||
|
@group(0) @binding(1) var<storage, read> rectLights: array<vec4<f32>>;
|
||||||
|
@group(0) @binding(2) var<storage, read> spotLights: array<vec4<f32>>;
|
||||||
|
@group(0) @binding(3) var<storage, read> directionalLights: array<vec4<f32>>;
|
||||||
|
@group(0) @binding(4) var<storage, read> ambientLights: array<vec4<f32>>;
|
||||||
|
@group(0) @binding(5) var<storage, read_write> clusters: array<u32>;
|
||||||
|
|
||||||
|
@compute @workgroup_size(64)
|
||||||
|
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||||
|
if (id.x != 0u) { return; }
|
||||||
|
var count = 0u;
|
||||||
|
var light = vec3<f32>(0.0);
|
||||||
|
for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) {
|
||||||
|
let enabled = pointLights[i * 2u + 1u].y;
|
||||||
|
count += select(0u, 1u, enabled != 0.0);
|
||||||
|
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<u32>(light.r);
|
||||||
|
clusters[2] = bitcast<u32>(light.g);
|
||||||
|
clusters[3] = bitcast<u32>(light.b);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const forwardShader = /* wgsl */ `
|
||||||
|
struct Accent { color: vec4<f32> }
|
||||||
|
struct VertexOutput {
|
||||||
|
@builtin(position) position: vec4<f32>,
|
||||||
|
@location(0) normal: vec3<f32>,
|
||||||
|
@location(1) @interpolate(flat) mesh: u32,
|
||||||
|
@location(2) @interpolate(flat) material: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<storage, read> clusters: array<u32>;
|
||||||
|
@group(0) @binding(1) var<uniform> accent: Accent;
|
||||||
|
@group(0) @binding(2) var<storage, read> positions: array<vec4<f32>>;
|
||||||
|
@group(0) @binding(3) var<storage, read> quaternions: array<vec4<f32>>;
|
||||||
|
@group(0) @binding(4) var<storage, read> scales: array<vec4<f32>>;
|
||||||
|
@group(0) @binding(5) var<storage, read> meshInfo: array<u32>;
|
||||||
|
@group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>;
|
||||||
|
@group(0) @binding(7) var<storage, read> cameras: array<vec4<f32>>;
|
||||||
|
|
||||||
|
fn rotate(q: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
|
||||||
|
return value + 2.0 * cross(q.xyz, cross(q.xyz, value) + q.w * value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vertex(@location(0) point: vec3<f32>, @builtin(instance_index) packed: u32) -> VertexOutput {
|
||||||
|
let instance = packed & 65535u;
|
||||||
|
let visible = meshInfo[instance * 4u + 2u];
|
||||||
|
let transformed = rotate(quaternions[instance], point * scales[instance].xyz) + positions[instance].xyz;
|
||||||
|
var clip = vec4<f32>(transformed, 1.0);
|
||||||
|
if (cameras[2].w != 0.0) {
|
||||||
|
let cameraNode = u32(cameras[1].x);
|
||||||
|
let inverse = vec4<f32>(-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<f32>(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<f32>(view.x * focal / cameras[0].y, view.y * focal, depth, -view.z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var output: VertexOutput;
|
||||||
|
output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), clip, visible != 0u);
|
||||||
|
output.normal = normalize(rotate(quaternions[instance], vec3<f32>(0.0, 0.0, 1.0)));
|
||||||
|
output.mesh = instance;
|
||||||
|
output.material = packed >> 16u;
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
let fallback = meshInfo[input.mesh * 4u + 1u];
|
||||||
|
let material = select(fallback, input.material - 1u, input.material != 0u);
|
||||||
|
let base = materials[material * 3u];
|
||||||
|
let properties = materials[material * 3u + 1u];
|
||||||
|
let clusterLight = vec3<f32>(bitcast<f32>(clusters[1]), bitcast<f32>(clusters[2]), bitcast<f32>(clusters[3]));
|
||||||
|
let light = vec3<f32>(0.12 + max(dot(input.normal, normalize(vec3<f32>(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<f32>(color, base.a);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const emptyForwardShader = /* wgsl */ `
|
||||||
|
struct Accent { color: vec4<f32> }
|
||||||
|
@group(0) @binding(0) var<uniform> accent: Accent;
|
||||||
|
struct VertexOutput { @builtin(position) position: vec4<f32> }
|
||||||
|
@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<f32> {
|
||||||
|
return vec4(accent.color.rgb, 1.0);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const fullscreenVertex = /* wgsl */ `
|
||||||
|
struct VertexOutput {
|
||||||
|
@builtin(position) position: vec4<f32>,
|
||||||
|
@location(0) uv: vec2<f32>,
|
||||||
|
}
|
||||||
|
@vertex fn vertex(@builtin(vertex_index) index: u32) -> VertexOutput {
|
||||||
|
let points = array(vec2(-1.0, -3.0), vec2(3.0, 1.0), vec2(-1.0, 1.0));
|
||||||
|
var output: VertexOutput;
|
||||||
|
output.position = vec4(points[index], 0.0, 1.0);
|
||||||
|
output.uv = points[index] * vec2(0.5, -0.5) + vec2(0.5);
|
||||||
|
return output;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
function effectFragment(kind: string, options: Record<string, unknown>) {
|
||||||
|
const amount = Number(options.amount ?? options.exposure ?? 1);
|
||||||
|
const safeAmount = Number.isFinite(amount) ? amount : 1;
|
||||||
|
const body: Record<string, string> = {
|
||||||
|
ssao: `let value = textureSample(source, sourceSampler, input.uv); return vec4(value.rgb * ${Math.max(0, 1 - safeAmount * 0.2)}, value.a);`,
|
||||||
|
fxaa: `let size = vec2<f32>(textureDimensions(source)); let pixel = 1.0 / size; let center = textureSample(source, sourceSampler, input.uv); let around = textureSample(source, sourceSampler, input.uv + vec2(pixel.x, 0.0)) + textureSample(source, sourceSampler, input.uv - vec2(pixel.x, 0.0)) + textureSample(source, sourceSampler, input.uv + vec2(0.0, pixel.y)) + textureSample(source, sourceSampler, input.uv - vec2(0.0, pixel.y)); return mix(center, around * 0.25, 0.35);`,
|
||||||
|
colorGrading: `let value = textureSample(source, sourceSampler, input.uv); return vec4(pow(max(value.rgb * ${safeAmount}, vec3(0.0)), vec3(1.0 / 2.2)), value.a);`,
|
||||||
|
dynamicExposure: `let value = textureSample(source, sourceSampler, input.uv); return vec4(value.rgb * ${safeAmount}, value.a);`,
|
||||||
|
silhouette: `let size = vec2<f32>(textureDimensions(source)); let pixel = 1.0 / size; let value = textureSample(source, sourceSampler, input.uv); let edge = length(value.rgb - textureSample(source, sourceSampler, input.uv + pixel).rgb); return vec4(mix(value.rgb, vec3(0.0), smoothstep(0.08, 0.2, edge)), value.a);`,
|
||||||
|
edges: `let size = vec2<f32>(textureDimensions(source)); let pixel = 1.0 / size; let value = textureSample(source, sourceSampler, input.uv); let dx = length(value.rgb - textureSample(source, sourceSampler, input.uv + vec2(pixel.x, 0.0)).rgb); let dy = length(value.rgb - textureSample(source, sourceSampler, input.uv + vec2(0.0, pixel.y)).rgb); return vec4(vec3(max(dx, dy) * ${safeAmount}), value.a);`,
|
||||||
|
};
|
||||||
|
return `${fullscreenVertex}
|
||||||
|
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||||
|
@group(0) @binding(1) var sourceSampler: sampler;
|
||||||
|
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
${body[kind] ?? "return textureSample(source, sourceSampler, input.uv);"}
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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<f32>;
|
||||||
|
@group(0) @binding(1) var sourceSampler: sampler;
|
||||||
|
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
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<string, unknown>)[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<this>;
|
||||||
|
readonly hdr: boolean;
|
||||||
|
#graphUpdates = Promise.resolve();
|
||||||
|
#computePasses = new Map<string, ComputePass>();
|
||||||
|
#meshes = new Map<number, MeshLike>();
|
||||||
|
#shaders = new Map<number, ShaderLike>();
|
||||||
|
#effects = new Map<string, PostProcessState>();
|
||||||
|
#textures = new Map<number, TextureState>();
|
||||||
|
#geometry = new Map<number, Map<string, Float32Array | Uint32Array>>();
|
||||||
|
#geometryRefs = new Map<number, number>();
|
||||||
|
#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<number>, updateGraph = true) {
|
||||||
|
const components: Record<string, number> = { 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<TextureState, "number">) {
|
||||||
|
const number = this.#nextTexture++;
|
||||||
|
this.#textures.set(number, { ...texture, number });
|
||||||
|
return { number, ready: this.updateRenderGraph() };
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterTexture(number: number) {
|
||||||
|
this.#textures.delete(number);
|
||||||
|
return this.updateRenderGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPostProcess(effect: PostProcessState, enabled: boolean) {
|
||||||
|
if (enabled) this.#effects.set(effect.id, effect);
|
||||||
|
else this.#effects.delete(effect.id);
|
||||||
|
return this.updateRenderGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, GraphBuffer>();
|
||||||
|
const textures = new Map<string, GraphTexture>();
|
||||||
|
const samplers = new Map<string, GraphSampler>();
|
||||||
|
const computePipelines: object[] = [];
|
||||||
|
const renderPipelines: object[] = [];
|
||||||
|
const passes: object[] = [];
|
||||||
|
const addBuffer = (value: GraphBuffer) => buffers.set(value.id, value);
|
||||||
|
const addTexture = (value: GraphTexture) => textures.set(value.id, value);
|
||||||
|
const addSampler = (value: GraphSampler) => samplers.set(value.id, value);
|
||||||
|
|
||||||
|
for (const [id, array] of [
|
||||||
|
["point-lights", "pointLights"], ["rect-lights", "rectAreaLights"],
|
||||||
|
["spot-lights", "spotLights"], ["directional-lights", "directionalLights"],
|
||||||
|
["ambient-lights", "ambientLights"], ["clusters", "clusters"],
|
||||||
|
]) addBuffer({ id, array, usage: ["storage"] });
|
||||||
|
addBuffer({ id: "accent", array: "sceneAccent", usage: ["uniform"] });
|
||||||
|
|
||||||
|
computePipelines.push({ id: "cluster-lights", code: clusterShader, entry: "main" });
|
||||||
|
passes.push({
|
||||||
|
id: "cluster-lights", type: "compute", pipeline: "cluster-lights", dispatch: [4, 1, 1],
|
||||||
|
bindings: ["point-lights", "rect-lights", "spot-lights", "directional-lights", "ambient-lights", "clusters"]
|
||||||
|
.map((resource, binding) => ({ group: 0, binding, resource })),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const pass of this.#computePasses.values()) {
|
||||||
|
pass.buffers.forEach(addBuffer);
|
||||||
|
pass.textures.forEach(addTexture);
|
||||||
|
pass.samplers.forEach(addSampler);
|
||||||
|
computePipelines.push({ id: pass.id, code: pass.code, entry: pass.entry });
|
||||||
|
passes.push({
|
||||||
|
id: pass.id,
|
||||||
|
type: "compute",
|
||||||
|
pipeline: pass.id,
|
||||||
|
after: pass.after.length ? pass.after : ["cluster-lights"],
|
||||||
|
bindings: pass.bindings,
|
||||||
|
dispatch: pass.dispatch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const computeIds = [...this.#computePasses.keys()];
|
||||||
|
const 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<f32>; @group(0) @binding(1) var<storage, read_write> output: array<u32>; @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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
#worker: Worker;
|
||||||
|
#next = 1;
|
||||||
|
#pending = new Map<number, { resolve: (value: any) => 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<number>, direction: ArrayLike<number>): Promise<PickHit[]> {
|
||||||
|
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<any>((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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, SharedRows> = {};
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<number>;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<number>) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
#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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<number, { resolve: (value: any) => 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<any>((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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
const decoder = new TextDecoder();
|
||||||
|
const widths: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
|
||||||
|
const sizes: Record<number, number> = { 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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Node, type NodeOptions } from "../Node";
|
||||||
|
import type { Scene } from "../Scene";
|
||||||
|
|
||||||
|
export type AmbientLightOptions = NodeOptions & { color?: ArrayLike<number>; 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Node, type NodeOptions } from "../Node";
|
||||||
|
import type { Scene } from "../Scene";
|
||||||
|
|
||||||
|
export type DirectionalLightOptions = NodeOptions & { color?: ArrayLike<number>; 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Node, type NodeOptions } from "../Node";
|
||||||
|
import type { Scene } from "../Scene";
|
||||||
|
|
||||||
|
export type PointLightOptions = NodeOptions & { color?: ArrayLike<number>; 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<number>) { 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Node, type NodeOptions } from "../Node";
|
||||||
|
import type { Scene } from "../Scene";
|
||||||
|
|
||||||
|
export type RectAreaLightOptions = NodeOptions & {
|
||||||
|
color?: ArrayLike<number>;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Node, type NodeOptions } from "../Node";
|
||||||
|
import type { Scene } from "../Scene";
|
||||||
|
|
||||||
|
export type SpotLightOptions = NodeOptions & {
|
||||||
|
color?: ArrayLike<number>;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { Scene } from "../Scene";
|
||||||
|
import type { Texture } from "./Texture";
|
||||||
|
|
||||||
|
export type PBRMaterialOptions = {
|
||||||
|
baseColor?: ArrayLike<number>;
|
||||||
|
metallic?: number;
|
||||||
|
roughness?: number;
|
||||||
|
emissive?: ArrayLike<number>;
|
||||||
|
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<this>;
|
||||||
|
#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<number>) {
|
||||||
|
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<number>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<this>;
|
||||||
|
#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<ShaderMaterialOptions>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
#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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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))})`;
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
@@ -77,6 +77,18 @@ export class YawnCore {
|
|||||||
return array;
|
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) {
|
async deleteRows(name) {
|
||||||
await this.ready;
|
await this.ready;
|
||||||
await this.#request("delete-rows", { name });
|
await this.#request("delete-rows", { name });
|
||||||
|
|||||||
+39
@@ -1,6 +1,7 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
#[path = "render_graph/compiler.rs"]
|
#[path = "render_graph/compiler.rs"]
|
||||||
@@ -22,6 +23,14 @@ use render::RenderLoop;
|
|||||||
use render_data::RenderData;
|
use render_data::RenderData;
|
||||||
use store::Store;
|
use store::Store;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RowRequest {
|
||||||
|
name: String,
|
||||||
|
rows: u32,
|
||||||
|
stride: u32,
|
||||||
|
format: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub struct Core {
|
pub struct Core {
|
||||||
data: Rc<RefCell<RenderData>>,
|
data: Rc<RefCell<RenderData>>,
|
||||||
@@ -79,6 +88,36 @@ impl Core {
|
|||||||
Ok(serde_json::to_string(&rows).unwrap())
|
Ok(serde_json::to_string(&rows).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_rows_batch(&self, source: &str) -> Result<String, JsError> {
|
||||||
|
let requests: Vec<RowRequest> =
|
||||||
|
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::<Result<Vec<_>, _>>()?
|
||||||
|
};
|
||||||
|
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> {
|
pub fn delete_rows(&self, name: String) -> Result<(), JsError> {
|
||||||
if self.store.borrow().uses_rows(&name) {
|
if self.store.borrow().uses_rows(&name) {
|
||||||
return Err(JsError::new("ROWS_ACTIVE"));
|
return Err(JsError::new("ROWS_ACTIVE"));
|
||||||
|
|||||||
@@ -54,7 +54,14 @@ impl Store {
|
|||||||
if !self.uses_rows(name) {
|
if !self.uses_rows(name) {
|
||||||
return Ok(());
|
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)?;
|
let resources = GpuResources::activate(&graph, gpu, data)?;
|
||||||
self.active = Some(Loadout { graph, resources });
|
self.active = Some(Loadout { graph, resources });
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ addEventListener("message", async ({ data: message }) => {
|
|||||||
case "create-rows":
|
case "create-rows":
|
||||||
result = JSON.parse(core.create_rows(message.name, message.rows, message.stride, message.format));
|
result = JSON.parse(core.create_rows(message.name, message.rows, message.stride, message.format));
|
||||||
break;
|
break;
|
||||||
|
case "create-rows-batch":
|
||||||
|
result = JSON.parse(core.create_rows_batch(JSON.stringify(message.rows)));
|
||||||
|
break;
|
||||||
case "delete-rows":
|
case "delete-rows":
|
||||||
core.delete_rows(message.name);
|
core.delete_rows(message.name);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUnmounted, ref } from "vue";
|
import { onMounted, onUnmounted, ref } from "vue";
|
||||||
import { YawnCore } from "@yawn/core";
|
import {
|
||||||
import { loadGraph } from "@yawn/render-graph-js";
|
ComputePass,
|
||||||
import { triangleGraph } from "@yawn/default-pipelines";
|
FXAA,
|
||||||
|
Mesh,
|
||||||
|
PBRMaterial,
|
||||||
|
PointLight,
|
||||||
|
Scene,
|
||||||
|
} from "@yawn/handles";
|
||||||
|
|
||||||
|
const props = defineProps({ example: { type: String, default: "triangle" } });
|
||||||
|
|
||||||
const canvas = ref();
|
const canvas = ref();
|
||||||
const status = ref("Starting…");
|
const status = ref("Starting…");
|
||||||
const failed = ref(false);
|
const failed = ref(false);
|
||||||
let core;
|
let scene;
|
||||||
let color;
|
let accent;
|
||||||
|
|
||||||
function move(event) {
|
function move(event) {
|
||||||
if (!color) return;
|
if (!accent) return;
|
||||||
const bounds = canvas.value.getBoundingClientRect();
|
const bounds = canvas.value.getBoundingClientRect();
|
||||||
const row = color.row(0);
|
const row = accent.row(0);
|
||||||
row[0] = (event.clientX - bounds.left) / bounds.width;
|
row[0] = (event.clientX - bounds.left) / bounds.width;
|
||||||
row[1] = 1 - (event.clientY - bounds.top) / bounds.height;
|
row[1] = 1 - (event.clientY - bounds.top) / bounds.height;
|
||||||
}
|
}
|
||||||
@@ -23,18 +30,47 @@ onMounted(async () => {
|
|||||||
if (!crossOriginIsolated) throw new Error("Cross-origin isolation is disabled");
|
if (!crossOriginIsolated) throw new Error("Cross-origin isolation is disabled");
|
||||||
canvas.value.width = 960;
|
canvas.value.width = 960;
|
||||||
canvas.value.height = 540;
|
canvas.value.height = 540;
|
||||||
core = new YawnCore(canvas.value);
|
scene = new Scene(canvas.value, { hdr: true });
|
||||||
await core.ready;
|
await scene.ready;
|
||||||
color = await core.createRows({
|
accent = scene.array("sceneAccent");
|
||||||
name: "triangle.color",
|
|
||||||
rows: 1,
|
const material = new PBRMaterial(scene, {
|
||||||
stride: 16,
|
baseColor: props.example === "lights" ? [1, 0.55, 0.18, 1] : [0.75, 0.9, 1, 1],
|
||||||
format: "f32",
|
metallic: props.example === "materials" ? 0.9 : 0.1,
|
||||||
|
roughness: 0.38,
|
||||||
});
|
});
|
||||||
color.write(0, [0.2, 0.65, 1, 1]);
|
await material.ready;
|
||||||
await loadGraph(core, triangleGraph());
|
const mesh = new Mesh(scene, {
|
||||||
window.__yawnPlayground = { core, color };
|
material,
|
||||||
status.value = "Running · move the pointer to write the shared color row";
|
vertexData: {
|
||||||
|
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await mesh.ready;
|
||||||
|
|
||||||
|
if (props.example === "instances") {
|
||||||
|
mesh.position = [-0.45, 0, 0];
|
||||||
|
mesh.scale = [0.65, 0.65, 1];
|
||||||
|
const clone = mesh.clone({ position: [0.45, 0, 0], scale: [0.65, 0.65, 1] });
|
||||||
|
await clone.ready;
|
||||||
|
} else if (props.example === "lights") {
|
||||||
|
await new PointLight(scene, { position: [0, 0.4, 0.2], color: [1, 0.4, 0.1], intensity: 8 }).ready;
|
||||||
|
} else if (props.example === "post") {
|
||||||
|
await new FXAA(scene).ready;
|
||||||
|
} else if (props.example === "compute") {
|
||||||
|
await scene.ensureRows("playgroundCompute", 1, 16, "u32");
|
||||||
|
const compute = new ComputePass({
|
||||||
|
id: "playground-compute",
|
||||||
|
code: "@group(0) @binding(0) var<storage, read_write> value: array<u32>; @compute @workgroup_size(1) fn main() { value[0] = value[0] + 1u; }",
|
||||||
|
buffers: [{ id: "playground-value", array: "playgroundCompute", usage: ["storage"] }],
|
||||||
|
bindings: [{ group: 0, binding: 0, resource: "playground-value" }],
|
||||||
|
});
|
||||||
|
await scene.addComputePass(compute);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__yawnPlayground = { scene, mesh, material, accent };
|
||||||
|
status.value = `${props.example} running · pointer movement writes sceneAccent in the SAB`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed.value = true;
|
failed.value = true;
|
||||||
status.value = error.message;
|
status.value = error.message;
|
||||||
@@ -43,7 +79,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
delete window.__yawnPlayground;
|
delete window.__yawnPlayground;
|
||||||
core?.dispose();
|
scene?.dispose();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,29 @@ export default defineConfig({
|
|||||||
cleanUrls: true,
|
cleanUrls: true,
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
nav: [
|
nav: [
|
||||||
{ text: "Architecture", link: "/" },
|
{ text: "Guide", link: "/guide/getting-started" },
|
||||||
|
{ text: "Core", link: "/guide/core" },
|
||||||
{ text: "Playground", link: "/playground" },
|
{ 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: {
|
vite: {
|
||||||
plugins: [isolation],
|
plugins: [isolation],
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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<storage, read_write> velocity: array<vec4<f32>>;
|
||||||
|
|
||||||
|
@compute @workgroup_size(64)
|
||||||
|
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||||
|
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);
|
||||||
|
```
|
||||||
|
|
||||||
|
<Playground example="compute" />
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Playground from "../.vitepress/Playground.vue";
|
||||||
|
</script>
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
<canvas id="view"></canvas>
|
||||||
|
<script type="module" src="/src/app.ts"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Start a Scene
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Scene } from "@yawn/handles";
|
||||||
|
|
||||||
|
const canvas = document.querySelector<HTMLCanvasElement>("#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.
|
||||||
|
|
||||||
|
<Playground />
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Playground from "../.vitepress/Playground.vue";
|
||||||
|
</script>
|
||||||
@@ -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();
|
||||||
|
```
|
||||||
@@ -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]);
|
||||||
|
```
|
||||||
|
|
||||||
|
<Playground example="lights" />
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Playground from "../.vitepress/Playground.vue";
|
||||||
|
</script>
|
||||||
@@ -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;
|
||||||
|
```
|
||||||
|
|
||||||
|
<Playground example="materials" />
|
||||||
|
|
||||||
|
## 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<f32> }
|
||||||
|
@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<f32> {
|
||||||
|
return vec4(1., .2, .7, 1.);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
await shader.ready;
|
||||||
|
```
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Playground from "../.vitepress/Playground.vue";
|
||||||
|
</script>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<Playground example="instances" />
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Playground from "../.vitepress/Playground.vue";
|
||||||
|
</script>
|
||||||
@@ -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]);
|
||||||
|
```
|
||||||
|
|
||||||
|
<Playground example="post" />
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Playground from "../.vitepress/Playground.vue";
|
||||||
|
</script>
|
||||||
@@ -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.
|
||||||
+32
-24
@@ -2,41 +2,49 @@
|
|||||||
layout: home
|
layout: home
|
||||||
hero:
|
hero:
|
||||||
name: Yawn
|
name: Yawn
|
||||||
text: Shared render data and a render graph.
|
text: Render graphs over shared data.
|
||||||
tagline: One Rust/WASM core, one fixed arena, no built-in scene model or shader.
|
tagline: A small Rust/WASM core with optional conventional TypeScript handles.
|
||||||
actions:
|
actions:
|
||||||
- theme: brand
|
- theme: brand
|
||||||
text: Open the playground
|
text: Start the tutorial
|
||||||
|
link: /guide/getting-started
|
||||||
|
- theme: alt
|
||||||
|
text: Open playground
|
||||||
link: /playground
|
link: /playground
|
||||||
features:
|
features:
|
||||||
- title: Shared rows
|
- title: Hot state stays shared
|
||||||
details: Allocate an SOA row array once by message, then mutate its SAB views directly from any thread.
|
details: Transform, material, light, and camera changes are direct SharedArrayBuffer writes from any thread.
|
||||||
- title: External graphs
|
- title: Graph-authored GPU work
|
||||||
details: JSO and FXNode addons serialize DAGs to the S-expression AST consumed by the worker.
|
details: WGSL, pipelines, compute, HDR, and post effects live in one externally supplied DAG loadout.
|
||||||
- title: Up-front loadouts
|
- title: Conventional when wanted
|
||||||
details: Pipelines, GPU resources, pass order, and compatible transient aliases are prepared before activation.
|
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
|
```ts
|
||||||
const color = await core.allocateRows({
|
import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||||
name: "triangle.color",
|
|
||||||
rows: 1,
|
const scene = new Scene(document.querySelector("canvas"), { hdr: true });
|
||||||
stride: 16,
|
await scene.ready;
|
||||||
format: "f32",
|
|
||||||
|
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]);
|
mesh.position[0] = 0.25; // direct SAB mutation
|
||||||
color.row(0)[0] = 0.8; // direct SharedArrayBuffer write
|
|
||||||
await loadGraph(core, graph); // infrequent message
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`@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
|
```text
|
||||||
JSO / FXNode ──▶ AST ──▶ S-expression ──▶ Rust/WASM ──▶ worker ──▶ WebGPU
|
handles ──▶ graph AST ──▶ S-expression ──▶ core worker ──▶ Rust/WebGPU
|
||||||
any JS thread ────────────── direct SAB row writes ────────────────────┘
|
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.
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Minimal playground
|
# 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.
|
||||||
|
|
||||||
<Playground />
|
<Playground />
|
||||||
|
|
||||||
|
|||||||
Generated
+5
-48
@@ -15,34 +15,11 @@
|
|||||||
"vitepress": "^1.6.4"
|
"vitepress": "^1.6.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"addons/default-pipelines": {
|
"addons/handles": {
|
||||||
"name": "@yawn/default-pipelines",
|
"name": "@yawn/handles",
|
||||||
"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",
|
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@yawn/render-graph-ast": "0.1.0"
|
"@yawn/core": "0.1.0"
|
||||||
}
|
|
||||||
},
|
|
||||||
"addons/render-graph-js": {
|
|
||||||
"name": "@yawn/render-graph-js",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@yawn/render-graph-ast": "0.1.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"core": {
|
"core": {
|
||||||
@@ -1245,28 +1222,8 @@
|
|||||||
"resolved": "core",
|
"resolved": "core",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@yawn/default-pipelines": {
|
"node_modules/@yawn/handles": {
|
||||||
"resolved": "addons/default-pipelines",
|
"resolved": "addons/handles",
|
||||||
"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",
|
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/algoliasearch": {
|
"node_modules/algoliasearch": {
|
||||||
|
|||||||
Reference in New Issue
Block a user