Strip core to render data and render graphs

Move glTF, picking, camera controls, and conventional handles into addons. Keep camera and material mutations in SIMD-aligned shared SOA rows and synchronize material updates directly into GPU buffers.

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:
Amp
2026-08-19 09:41:41 +00:00
co-authored by heaust
parent 0e44917f9e
commit 6bbf8039e4
67 changed files with 3152 additions and 3669 deletions
+392
View File
@@ -0,0 +1,392 @@
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));
}
+30 -5
View File
@@ -6,7 +6,31 @@ export class GltfImportError extends Error {
}
}
/** Fetches glTF in a dedicated worker and commits only shared-memory upload metadata. */
function frameCamera(core, bounds, framing) {
if (!bounds || framing === false) return;
if (framing !== undefined && framing !== "exterior" && framing !== "interior")
throw new TypeError("framing must be exterior, interior, or false");
const min = bounds.min, max = bounds.max;
if (!Array.isArray(min) || !Array.isArray(max) || min.length !== 3 || max.length !== 3) return;
const center = min.map((value, axis) => (value + max[axis]) * 0.5);
const extent = max.map((value, axis) => value - min[axis]);
const radius = Math.max(1, Math.hypot(...extent) * 0.5);
const camera = core.array("camera.state");
const state = camera.read(0);
const interior = framing === "interior";
const eye = interior
? [center[0], center[1] + radius * 0.05, center[2]]
: [center[0] + radius * 1.8, center[1] + radius * 1.4, center[2] + radius * 1.8];
const target = interior ? [center[0] + radius, center[1], center[2]] : center;
state.splice(0, 3, ...eye);
state.splice(4, 3, ...target);
state.splice(8, 3, 0, 1, 0);
state[14] = Math.max(radius * 0.001, 0.1);
state[15] = Math.max(radius * 6, 1.1);
camera.write(0, state);
}
/** Parses glTF in a dedicated worker and publishes a generic render-data packet through shared memory. */
export class GltfImporter {
#core;
#worker;
@@ -16,7 +40,8 @@ export class GltfImporter {
#disposed = false;
constructor(core, { workerFactory } = {}) {
if (!core?.allocateArray || !core?.commitGlbUpload) throw new TypeError("core must implement the Yawn shared upload protocol");
if (!core?.allocateArray || !core?.commitRenderDataUpload)
throw new TypeError("core must implement the Yawn shared render-data protocol");
this.#core = core;
this.#worker = workerFactory
? workerFactory()
@@ -55,7 +80,7 @@ export class GltfImporter {
if (message.type === "allocate") {
const length = Math.ceil(message.byteLength / 16);
pending.array = await this.#core.allocateArray({
name: "upload.gltf",
name: "upload.renderData",
domain: "fixed",
scalar: "u32",
lanes: 4,
@@ -68,11 +93,11 @@ export class GltfImporter {
...pending.array.share(),
});
} else if (message.type === "ready") {
const result = await this.#core.commitGlbUpload(
const result = await this.#core.commitRenderDataUpload(
pending.array,
message.byteLength,
pending.options,
);
frameCamera(this.#core, result.bounds, pending.options.framing);
this.#pending.delete(message.request);
pending.resolve(result);
} else if (message.type === "error") {
+8 -6
View File
@@ -1,4 +1,5 @@
import { writeSharedUpload } from "./shared-upload.js";
import { gltfToRenderDataPacket } from "./gltf.js";
const downloads = new Map();
@@ -10,16 +11,17 @@ addEventListener("message", async ({ data: message }) => {
if (!response.ok) throw new Error(`HTTP_${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
if (!bytes.byteLength) throw new Error("GLTF_EMPTY");
downloads.set(request, bytes);
postMessage({ type: "allocate", request, byteLength: bytes.byteLength });
const packet = await gltfToRenderDataPacket(bytes, message.url);
downloads.set(request, packet);
postMessage({ type: "allocate", request, byteLength: packet.byteLength });
return;
}
if (message?.type === "storage") {
const bytes = downloads.get(request);
if (!bytes) throw new Error("GLTF_REQUEST_UNKNOWN");
writeSharedUpload(message.buffer, message.descriptor, bytes);
const packet = downloads.get(request);
if (!packet) throw new Error("GLTF_REQUEST_UNKNOWN");
writeSharedUpload(message.buffer, message.descriptor, packet);
downloads.delete(request);
postMessage({ type: "ready", request, byteLength: bytes.byteLength });
postMessage({ type: "ready", request, byteLength: packet.byteLength });
}
} catch (error) {
downloads.delete(request);