Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d279ec4123 | ||
|
|
d930d8172f | ||
|
|
347b676cf1 | ||
|
|
eb8aa0e9dc | ||
|
|
bc957cf40d | ||
|
|
a4f3eeddd6 | ||
|
|
f324ff6bb2 | ||
|
|
92638e6c9f | ||
|
|
31d3ae21ff | ||
|
|
49c80a209e | ||
|
|
dda602c4a2 | ||
|
|
e7546e4b78 | ||
|
|
d71da85307 | ||
|
|
6153223fd5 | ||
|
|
951449db0b |
@@ -18,7 +18,12 @@ if ! command -v git-lfs >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y git-lfs
|
||||
fi
|
||||
if ! command -v Xvfb >/dev/null || ! command -v magick >/dev/null || ! command -v xauth >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y imagemagick libvulkan1 mesa-vulkan-drivers xauth xvfb
|
||||
fi
|
||||
git lfs install --local
|
||||
git lfs pull --include="docs/public/models/sponza.glb"
|
||||
npm ci
|
||||
npx agent-browser install
|
||||
amp orb services ensure
|
||||
|
||||
@@ -4,3 +4,4 @@ services:
|
||||
portal:
|
||||
title: Yawn
|
||||
description: Yawn website, tutorial, package server, and WebGPU playground.
|
||||
review: false
|
||||
|
||||
@@ -10,6 +10,5 @@ core/pkg
|
||||
server/data
|
||||
docs
|
||||
coredocs
|
||||
vendor
|
||||
*.log
|
||||
*.local
|
||||
|
||||
@@ -32,6 +32,7 @@ docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
server/data/
|
||||
yawn-data/
|
||||
addons/handles/test/screenshots/output/
|
||||
|
||||
# Amp runtime artifacts
|
||||
.amp/in/
|
||||
|
||||
@@ -117,6 +117,15 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -717,6 +726,7 @@ dependencies = [
|
||||
name = "yawn-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -18,11 +18,11 @@ RUN toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" \
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY core/package.json ./core/package.json
|
||||
COPY addons/handles/package.json ./addons/handles/package.json
|
||||
COPY addons ./addons
|
||||
COPY vendor ./vendor
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --include=dev
|
||||
|
||||
COPY core ./core
|
||||
COPY addons/handles ./addons/handles
|
||||
COPY server/build.mjs ./server/build.mjs
|
||||
RUN --mount=type=cache,target=/src/target mkdir -p /out \
|
||||
&& wasm-pack build core --target web --out-dir /out/pkg --release \
|
||||
@@ -42,6 +42,9 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
ARG YAWN_DEPLOYED_AT=""
|
||||
ARG YAWN_DEPLOY_COMMIT_MESSAGE=""
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
@@ -58,7 +61,9 @@ COPY --from=package-builder --chown=yawn:yawn /src/node_modules/monaco-editor/mi
|
||||
ENV PORT=5002 \
|
||||
YAWN_ROOT=/app \
|
||||
YAWN_PACKAGES=/app/packages \
|
||||
YAWN_DATABASE=/data/yawn.db
|
||||
YAWN_DATABASE=/data/yawn.db \
|
||||
YAWN_DEPLOYED_AT="${YAWN_DEPLOYED_AT}" \
|
||||
YAWN_DEPLOY_COMMIT_MESSAGE="${YAWN_DEPLOY_COMMIT_MESSAGE}"
|
||||
|
||||
USER yawn:yawn
|
||||
VOLUME ["/data"]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@yawn/fxnode-rg",
|
||||
"version": "0.1.0",
|
||||
"description": "FxNode render-graph authoring and Yawn graph compilation",
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "node test/run.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"fxnode": "file:../../vendor/fxnode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
import type {
|
||||
GraphLayoutV2,
|
||||
GraphNode,
|
||||
GraphSnapshot,
|
||||
ParameterValue,
|
||||
} from "fxnode";
|
||||
import { isPhysicalRenderGraph, reconstructRenderGraph } from "./physical";
|
||||
|
||||
type PlainObject = Record<string, any>;
|
||||
type GraphSource = GraphLayoutV2 | GraphSnapshot;
|
||||
|
||||
const modeTypes = new Set([
|
||||
"yawn.rg.mode.beauty",
|
||||
"yawn.rg.mode.normals",
|
||||
"yawn.rg.mode.depth",
|
||||
"yawn.rg.mode.unlit",
|
||||
"yawn.rg.mode.object-id",
|
||||
]);
|
||||
|
||||
const effectTypes = new Set([
|
||||
"yawn.rg.post.fxaa",
|
||||
"yawn.rg.post.bloom",
|
||||
"yawn.rg.post.edges",
|
||||
"yawn.rg.post.color-grade",
|
||||
"yawn.rg.post.vignette",
|
||||
"yawn.rg.post.grayscale",
|
||||
"yawn.rg.post.invert",
|
||||
]);
|
||||
|
||||
function plainRecord(value: unknown): value is PlainObject {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function encodeValue(value: unknown): string {
|
||||
if (value === null || typeof value === "boolean") return String(value);
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value))
|
||||
throw new TypeError("Render graph numbers must be finite");
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (Array.isArray(value))
|
||||
return `(array${value.map((item) => ` ${encodeValue(item)}`).join("")})`;
|
||||
if (plainRecord(value))
|
||||
return `(object${Object.keys(value)
|
||||
.sort()
|
||||
.map(
|
||||
(key) => ` (field ${JSON.stringify(key)} ${encodeValue(value[key])})`,
|
||||
)
|
||||
.join("")})`;
|
||||
throw new TypeError("Render graph values must be plain data");
|
||||
}
|
||||
|
||||
/** Encodes a Yawn render graph into the core's versioned S-expression wire format. */
|
||||
export function encodeRenderGraph(graph: object): string {
|
||||
if (!plainRecord(graph))
|
||||
throw new TypeError("Render graph must be a plain object");
|
||||
return `(yawn-graph 1 ${encodeValue(graph)})`;
|
||||
}
|
||||
|
||||
function value<T>(node: GraphNode | any, key: string, fallback: T): T {
|
||||
const parameter = node.parameters?.[key] as ParameterValue | undefined;
|
||||
return (parameter && "value" in parameter ? parameter.value : fallback) as T;
|
||||
}
|
||||
|
||||
function activeChain(layout: GraphSource): any[] {
|
||||
if (!layout || !Array.isArray(layout.nodes) || !Array.isArray(layout.links))
|
||||
throw new TypeError("FxNode loadout is not a graph layout");
|
||||
const nodes = new Map(
|
||||
layout.nodes.map((node: any) => [String(node.id), node]),
|
||||
);
|
||||
const outputs = layout.nodes.filter(
|
||||
(node: any) => node.typeId === "yawn.rg.output.present" && !node.muted,
|
||||
);
|
||||
if (outputs.length !== 1)
|
||||
throw new Error("Loadout must contain exactly one active Present node");
|
||||
const incoming = new Map<string, any>();
|
||||
for (const link of layout.links) {
|
||||
if (link.muted) continue;
|
||||
if (incoming.has(String(link.toSocketId)))
|
||||
throw new Error(
|
||||
`Loadout input ${String(link.toSocketId)} has multiple links`,
|
||||
);
|
||||
incoming.set(String(link.toSocketId), link);
|
||||
}
|
||||
|
||||
const chain: any[] = [outputs[0]];
|
||||
const seen = new Set<string>();
|
||||
let current = outputs[0];
|
||||
while (current.typeId !== "yawn.rg.input.scene") {
|
||||
if (seen.has(String(current.id)))
|
||||
throw new Error("Loadout contains a cycle");
|
||||
seen.add(String(current.id));
|
||||
const link = incoming.get(`${String(current.id)}:surface`);
|
||||
if (!link)
|
||||
throw new Error(
|
||||
`${String(current.label || current.typeId)} is not connected to Scene`,
|
||||
);
|
||||
current = nodes.get(String(link.fromNodeId));
|
||||
if (!current)
|
||||
throw new Error(
|
||||
`Loadout link references missing node ${String(link.fromNodeId)}`,
|
||||
);
|
||||
if (current.muted) {
|
||||
const bypass = incoming.get(`${String(current.id)}:surface`);
|
||||
current = bypass ? nodes.get(String(bypass.fromNodeId)) : undefined;
|
||||
if (!current)
|
||||
throw new Error("Muted stage has no connected bypass input");
|
||||
}
|
||||
chain.unshift(current);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
function passResources(pass: PlainObject): string[] {
|
||||
return [
|
||||
...(pass.bindings ?? []),
|
||||
...(pass.color ?? []),
|
||||
...(pass.vertexBuffers ?? []),
|
||||
...(pass.depth ? [pass.depth] : []),
|
||||
...(pass.indexBuffer ? [pass.indexBuffer] : []),
|
||||
]
|
||||
.map((item: PlainObject) => item?.resource)
|
||||
.filter(
|
||||
(resource: unknown): resource is string => typeof resource === "string",
|
||||
);
|
||||
}
|
||||
|
||||
function debugShader(mode: string, normalBuffer: boolean, node: any): string {
|
||||
const contrast = Number(value(node, "contrast", 1.35));
|
||||
const clay = value<readonly number[]>(node, "color", [0.36, 0.68, 0.9, 1]);
|
||||
const usesNormals = mode === "normals" || mode === "unlit";
|
||||
const usesObjectId = mode === "object-id";
|
||||
const fragment =
|
||||
mode === "normals"
|
||||
? "return vec4<f32>(normalize(input.normal) * 0.5 + 0.5, 1.0);"
|
||||
: mode === "depth"
|
||||
? `let depth = pow(clamp(1.0 - input.position.z, 0.0, 1.0), ${(Math.max(0.01, contrast) * 0.12).toFixed(4)}); return vec4<f32>(vec3<f32>(depth), 1.0);`
|
||||
: mode === "unlit"
|
||||
? `let base = vec3<f32>(${Number(clay[0] ?? 0.36).toFixed(5)}, ${Number(clay[1] ?? 0.68).toFixed(5)}, ${Number(clay[2] ?? 0.9).toFixed(5)}); let light = 0.32 + max(dot(normalize(input.normal), normalize(vec3<f32>(0.35, 0.8, 0.45))), 0.0) * 0.68; return vec4<f32>(base * light, 1.0);`
|
||||
: "let seed = f32(input.objectId) * 0.61803398875; let color = 0.42 + 0.58 * cos(vec3<f32>(0.0, 2.1, 4.2) + seed * 6.28318); return vec4<f32>(color, 1.0);";
|
||||
return `
|
||||
struct VertexOutput {
|
||||
@invariant @builtin(position) position: vec4<f32>,
|
||||
${usesNormals ? "@location(0) normal: vec3<f32>," : ""}
|
||||
${usesObjectId ? `@location(${usesNormals ? 1 : 0}) @interpolate(flat) objectId: u32,` : ""}
|
||||
}
|
||||
@group(0) @binding(0) var<storage, read> positions: array<vec4<f32>>;
|
||||
@group(0) @binding(1) var<storage, read> rotors: array<vec4<f32>>;
|
||||
@group(0) @binding(2) var<storage, read> scales: array<vec4<f32>>;
|
||||
@group(0) @binding(3) var<storage, read> meshInfo: array<u32>;
|
||||
@group(0) @binding(4) var<storage, read> cameraMatrices: array<vec4<f32>>;
|
||||
fn rotate(rotor: vec4<f32>, point: vec3<f32>) -> vec3<f32> {
|
||||
return point + 2.0 * cross(rotor.xyz, cross(rotor.xyz, point) + rotor.w * point);
|
||||
}
|
||||
@vertex fn vertex(
|
||||
@location(0) point: vec3<f32>,
|
||||
${normalBuffer ? "@location(1) localNormal: vec3<f32>," : ""}
|
||||
@builtin(instance_index) packed: u32,
|
||||
) -> VertexOutput {
|
||||
let instance = packed & 65535u;
|
||||
let transformed = rotate(rotors[instance], point * scales[instance].xyz) + positions[instance].xyz;
|
||||
let world = vec4<f32>(transformed, 1.0);
|
||||
var output: VertexOutput;
|
||||
output.position = vec4<f32>(
|
||||
dot(cameraMatrices[0], world), dot(cameraMatrices[1], world),
|
||||
dot(cameraMatrices[2], world), dot(cameraMatrices[3], world));
|
||||
if (cameraMatrices[4].w == 0.0) { output.position = world; }
|
||||
output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), output.position, meshInfo[instance * 4u + 2u] != 0u);
|
||||
${usesNormals ? `output.normal = normalize(rotate(rotors[instance], ${normalBuffer ? "localNormal / scales[instance].xyz" : "vec3<f32>(0.0, 0.0, 1.0)"}));` : ""}
|
||||
${usesObjectId ? "output.objectId = instance;" : ""}
|
||||
return output;
|
||||
}
|
||||
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||
${fragment}
|
||||
}`;
|
||||
}
|
||||
|
||||
function applyDebugMode(graph: PlainObject, node: any): void {
|
||||
const mode = String(node.typeId).slice("yawn.rg.mode.".length);
|
||||
if (mode === "beauty") return;
|
||||
const renderPipelines = graph.pipelines?.render;
|
||||
if (!Array.isArray(renderPipelines) || !Array.isArray(graph.passes))
|
||||
throw new Error("Base render graph has no render pipeline declarations");
|
||||
const byId = new Map(
|
||||
renderPipelines.map((pipeline: any) => [pipeline.id, pipeline]),
|
||||
);
|
||||
const passesById = new Map(
|
||||
graph.passes.map((pass: PlainObject) => [pass.id, pass]),
|
||||
);
|
||||
const detachedComputePasses = new Set<string>();
|
||||
const generated = new Map<string, string>();
|
||||
for (const pass of graph.passes) {
|
||||
if (
|
||||
pass.type !== "render" ||
|
||||
typeof pass.pipeline !== "string" ||
|
||||
!pass.pipeline.startsWith("forward-") ||
|
||||
pass.pipeline === "empty-forward" ||
|
||||
pass.draw?.vertices === 0
|
||||
)
|
||||
continue;
|
||||
const original = byId.get(pass.pipeline);
|
||||
if (!original)
|
||||
throw new Error(`Forward pipeline ${pass.pipeline} is missing`);
|
||||
const detailed = (original.vertex?.buffers?.length ?? 0) >= 2;
|
||||
const format = original.fragment?.targets?.[0]?.format;
|
||||
if (typeof format !== "string")
|
||||
throw new Error(`Forward pipeline ${pass.pipeline} has no color target`);
|
||||
const normalBuffer = (mode === "normals" || mode === "unlit") && detailed;
|
||||
const key = `${mode}:${normalBuffer}:${format}`;
|
||||
let id = generated.get(key);
|
||||
if (!id) {
|
||||
id = `rg-${mode}-${normalBuffer ? "detailed" : "basic"}`;
|
||||
let suffix = 1;
|
||||
while (byId.has(id))
|
||||
id = `rg-${mode}-${normalBuffer ? "detailed" : "basic"}-${suffix++}`;
|
||||
const pipeline = {
|
||||
id,
|
||||
code: debugShader(mode, normalBuffer, node),
|
||||
vertex: {
|
||||
entry: "vertex",
|
||||
buffers: original.vertex.buffers.slice(0, normalBuffer ? 2 : 1),
|
||||
},
|
||||
fragment: { entry: "fragment", targets: [{ format }] },
|
||||
depthStencil: original.depthStencil ?? null,
|
||||
primitive: original.primitive ?? null,
|
||||
};
|
||||
renderPipelines.push(pipeline);
|
||||
byId.set(id, pipeline);
|
||||
generated.set(key, id);
|
||||
}
|
||||
pass.pipeline = id;
|
||||
const resources = [
|
||||
"node-positions",
|
||||
"node-rotors",
|
||||
"node-scales",
|
||||
"mesh-info",
|
||||
"camera-matrices",
|
||||
];
|
||||
pass.bindings = resources.map((resource, binding) => ({
|
||||
group: 0,
|
||||
binding,
|
||||
resource,
|
||||
}));
|
||||
pass.vertexBuffers = (pass.vertexBuffers ?? []).filter(
|
||||
(binding: any) =>
|
||||
binding.slot === 0 || (normalBuffer && binding.slot === 1),
|
||||
);
|
||||
const used = new Set(passResources(pass));
|
||||
pass.after = (pass.after ?? []).filter((dependency: string) => {
|
||||
const candidate = passesById.get(dependency);
|
||||
if (
|
||||
candidate?.type !== "compute" ||
|
||||
passResources(candidate).some((resource) => used.has(resource))
|
||||
)
|
||||
return true;
|
||||
detachedComputePasses.add(dependency);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
const retainedDependencies = new Set(
|
||||
graph.passes.flatMap((pass: PlainObject) => pass.after ?? []),
|
||||
);
|
||||
graph.passes = graph.passes.filter((pass: PlainObject) => {
|
||||
if (
|
||||
!detachedComputePasses.has(pass.id) ||
|
||||
retainedDependencies.has(pass.id)
|
||||
)
|
||||
return true;
|
||||
const resources = new Set(passResources(pass));
|
||||
return graph.passes.some(
|
||||
(other: PlainObject) =>
|
||||
other !== pass &&
|
||||
passResources(other).some((resource) => resources.has(resource)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
type Rgb = readonly [number, number, number];
|
||||
|
||||
const luma = ([red, green, blue]: Rgb) =>
|
||||
red * 0.2126 + green * 0.7152 + blue * 0.0722;
|
||||
|
||||
function rgbParameter(node: any, key: string): Rgb {
|
||||
const color = value<readonly number[]>(node, key, [1, 1, 1, 1]);
|
||||
return [Number(color[0] ?? 1), Number(color[1] ?? 1), Number(color[2] ?? 1)];
|
||||
}
|
||||
|
||||
function normalizedBalance(color: Rgb): Rgb {
|
||||
const luminance = Math.max(0.001, luma(color));
|
||||
return [color[0] / luminance, color[1] / luminance, color[2] / luminance];
|
||||
}
|
||||
|
||||
function mapRgb(
|
||||
color: Rgb,
|
||||
transform: (channel: number, index: number) => number,
|
||||
): Rgb {
|
||||
return [
|
||||
transform(color[0], 0),
|
||||
transform(color[1], 1),
|
||||
transform(color[2], 2),
|
||||
];
|
||||
}
|
||||
|
||||
function wgslRgb(color: Rgb): string {
|
||||
return `vec3<f32>(${color.map((channel) => channel.toFixed(5)).join(", ")})`;
|
||||
}
|
||||
|
||||
function colorTemperature(kelvin: number): Rgb {
|
||||
const temperature = Math.min(40000, Math.max(1000, kelvin)) / 100;
|
||||
const red =
|
||||
temperature <= 66
|
||||
? 255
|
||||
: 329.698727446 * Math.pow(temperature - 60, -0.1332047592);
|
||||
const green =
|
||||
temperature <= 66
|
||||
? 99.4708025861 * Math.log(temperature) - 161.1195681661
|
||||
: 288.1221695283 * Math.pow(temperature - 60, -0.0755148492);
|
||||
const blue =
|
||||
temperature >= 66
|
||||
? 255
|
||||
: temperature <= 19
|
||||
? 0
|
||||
: 138.5177312231 * Math.log(temperature - 10) - 305.0447927307;
|
||||
const linear = (channel: number) => {
|
||||
const srgb = Math.min(1, Math.max(0, channel / 255));
|
||||
return srgb <= 0.04045
|
||||
? srgb / 12.92
|
||||
: Math.pow((srgb + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return [linear(red), linear(green), linear(blue)];
|
||||
}
|
||||
|
||||
function whitePoint(node: any, prefix: "input" | "output"): Rgb {
|
||||
const temperature = colorTemperature(
|
||||
Number(value(node, `${prefix}Temperature`, 6500)),
|
||||
);
|
||||
const tint = Math.min(
|
||||
1,
|
||||
Math.max(-1, Number(value(node, `${prefix}Tint`, 0))),
|
||||
);
|
||||
const color = rgbParameter(node, `${prefix}Color`);
|
||||
return [
|
||||
temperature[0] * (1 - tint * 0.18) * color[0],
|
||||
temperature[1] * (1 + tint * 0.24) * color[1],
|
||||
temperature[2] * (1 - tint * 0.18) * color[2],
|
||||
];
|
||||
}
|
||||
|
||||
function colorBalanceExpression(node: any): string {
|
||||
const mode = String(value(node, "mode", "Lift/Gamma/Gain"));
|
||||
if (mode === "Offset/Power/Slope") {
|
||||
const offset = Number(value(node, "offset", 0));
|
||||
const power = Number(value(node, "power", 1));
|
||||
const slope = Number(value(node, "slope", 1));
|
||||
const offsetBalance = mapRgb(
|
||||
normalizedBalance(rgbParameter(node, "offsetColor")),
|
||||
(channel) => channel - 1 + offset,
|
||||
);
|
||||
const powerBalance = mapRgb(
|
||||
normalizedBalance(rgbParameter(node, "powerColor")),
|
||||
(channel) => Math.max(0.01, channel * power),
|
||||
);
|
||||
const slopeBalance = mapRgb(
|
||||
normalizedBalance(rgbParameter(node, "slopeColor")),
|
||||
(channel) => Math.max(0, channel * slope),
|
||||
);
|
||||
return `
|
||||
let offsetBalance = ${wgslRgb(offsetBalance)};
|
||||
let powerBalance = ${wgslRgb(powerBalance)};
|
||||
let slopeBalance = ${wgslRgb(slopeBalance)};
|
||||
color = pow(max(color * slopeBalance + offsetBalance, vec3<f32>(0.0)), powerBalance);`;
|
||||
}
|
||||
if (mode === "White Point") {
|
||||
const input = whitePoint(node, "input");
|
||||
const output = whitePoint(node, "output");
|
||||
const adaptation = mapRgb(
|
||||
output,
|
||||
(channel, index) => channel / Math.max(0.001, input[index]!),
|
||||
);
|
||||
return `
|
||||
let whitePointAdaptation = ${wgslRgb(adaptation)};
|
||||
color *= whitePointAdaptation;`;
|
||||
}
|
||||
|
||||
const lift = Number(value(node, "lift", 0));
|
||||
const gamma = Number(value(node, "gamma", 1));
|
||||
const gain = Number(value(node, "gain", 1));
|
||||
const liftBalance = mapRgb(
|
||||
normalizedBalance(rgbParameter(node, "liftColor")),
|
||||
(channel) => channel - 1 + lift,
|
||||
);
|
||||
const gammaBalance = mapRgb(
|
||||
normalizedBalance(rgbParameter(node, "gammaColor")),
|
||||
(channel) => Math.max(0.01, channel * gamma),
|
||||
);
|
||||
const gainBalance = mapRgb(
|
||||
normalizedBalance(rgbParameter(node, "gainColor")),
|
||||
(channel) => Math.max(0, channel * gain),
|
||||
);
|
||||
return `
|
||||
let liftBalance = ${wgslRgb(liftBalance)};
|
||||
let gammaBalance = ${wgslRgb(gammaBalance)};
|
||||
let gainBalance = ${wgslRgb(gainBalance)};
|
||||
color = max(color + liftBalance, vec3<f32>(0.0));
|
||||
color = pow(color, vec3<f32>(1.0) / gammaBalance) * gainBalance;`;
|
||||
}
|
||||
|
||||
const fullScreenVertex = `
|
||||
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<f32>, 3>(vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.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 effectExpression(node: any): string {
|
||||
if (node.typeId === "yawn.rg.post.fxaa")
|
||||
return `
|
||||
let size = vec2<f32>(textureDimensions(source));
|
||||
let step = 1.0 / size;
|
||||
let center = textureSample(source, sourceSampler, input.uv);
|
||||
let horizontal = textureSample(source, sourceSampler, input.uv + vec2(step.x, 0.0)) + textureSample(source, sourceSampler, input.uv - vec2(step.x, 0.0));
|
||||
let vertical = textureSample(source, sourceSampler, input.uv + vec2(0.0, step.y)) + textureSample(source, sourceSampler, input.uv - vec2(0.0, step.y));
|
||||
return center * 0.5 + (horizontal + vertical) * 0.125;`;
|
||||
if (node.typeId === "yawn.rg.post.bloom") {
|
||||
const threshold = Number(value(node, "threshold", 0.7));
|
||||
const strength = Number(value(node, "strength", 0.3));
|
||||
return `
|
||||
let size = vec2<f32>(textureDimensions(source));
|
||||
let step = 2.0 / size;
|
||||
let center = textureSample(source, sourceSampler, input.uv);
|
||||
var glow = vec4<f32>(0.0);
|
||||
glow += textureSample(source, sourceSampler, input.uv + vec2(step.x, 0.0));
|
||||
glow += textureSample(source, sourceSampler, input.uv - vec2(step.x, 0.0));
|
||||
glow += textureSample(source, sourceSampler, input.uv + vec2(0.0, step.y));
|
||||
glow += textureSample(source, sourceSampler, input.uv - vec2(0.0, step.y));
|
||||
glow *= 0.25;
|
||||
let bright = max(glow.rgb - vec3<f32>(${threshold.toFixed(5)}), vec3<f32>(0.0));
|
||||
return vec4<f32>(center.rgb + bright * ${strength.toFixed(5)}, center.a);`;
|
||||
}
|
||||
if (node.typeId === "yawn.rg.post.edges") {
|
||||
const strength = Number(value(node, "strength", 0.8));
|
||||
const style = String(value(node, "style", "ink"));
|
||||
if (style === "blueprint")
|
||||
return `
|
||||
let size = vec2<f32>(textureDimensions(source));
|
||||
let step = 1.0 / size;
|
||||
let center = textureSample(source, sourceSampler, input.uv);
|
||||
let right = textureSample(source, sourceSampler, input.uv + vec2(step.x, 0.0));
|
||||
let down = textureSample(source, sourceSampler, input.uv + vec2(0.0, step.y));
|
||||
let edge = length(center.rgb - right.rgb) + length(center.rgb - down.rgb);
|
||||
let value = dot(center.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
let cell = fract(input.uv * vec2<f32>(24.0, 16.0));
|
||||
let gridDistance = min(min(cell.x, 1.0 - cell.x), min(cell.y, 1.0 - cell.y));
|
||||
let grid = 1.0 - smoothstep(0.0, 0.035, gridDistance);
|
||||
var paper = mix(vec3<f32>(0.008, 0.045, 0.11), vec3<f32>(0.025, 0.24, 0.46), clamp(value, 0.0, 1.0));
|
||||
paper += grid * vec3<f32>(0.012, 0.045, 0.075);
|
||||
let line = clamp(smoothstep(0.035, 0.24, edge) * ${strength.toFixed(5)}, 0.0, 1.0);
|
||||
return vec4<f32>(mix(paper, vec3<f32>(0.68, 0.94, 1.0), line), 1.0);`;
|
||||
return `
|
||||
let size = vec2<f32>(textureDimensions(source));
|
||||
let step = 1.0 / size;
|
||||
let center = textureSample(source, sourceSampler, input.uv);
|
||||
let right = textureSample(source, sourceSampler, input.uv + vec2(step.x, 0.0));
|
||||
let down = textureSample(source, sourceSampler, input.uv + vec2(0.0, step.y));
|
||||
let edge = length(center.rgb - right.rgb) + length(center.rgb - down.rgb);
|
||||
let ink = smoothstep(0.05, 0.35, edge) * ${strength.toFixed(5)};
|
||||
return vec4<f32>(mix(center.rgb, vec3<f32>(0.015, 0.08, 0.14), ink), center.a);`;
|
||||
}
|
||||
if (node.typeId === "yawn.rg.post.color-grade") {
|
||||
const exposure = Number(value(node, "exposure", 0));
|
||||
const contrast = Number(value(node, "contrast", 1));
|
||||
const pivot = Number(value(node, "pivot", 0.18));
|
||||
const saturation = Number(value(node, "saturation", 1));
|
||||
const mixAmount = Number(value(node, "mix", 1));
|
||||
const tint = value<readonly number[]>(node, "tint", [0.5, 0.62, 0.72, 1]);
|
||||
const tintAmount = Number(value(node, "tintAmount", 0));
|
||||
return `
|
||||
let sourceColor = textureSample(source, sourceSampler, input.uv);
|
||||
var color = sourceColor.rgb * ${Math.pow(2, exposure).toFixed(5)};
|
||||
${colorBalanceExpression(node)}
|
||||
color = (color - vec3<f32>(${pivot.toFixed(5)})) * ${contrast.toFixed(5)} + vec3<f32>(${pivot.toFixed(5)});
|
||||
let luminance = dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
color = mix(vec3<f32>(luminance), color, ${saturation.toFixed(5)});
|
||||
color = mix(color, color * vec3<f32>(${Number(tint[0] ?? 0.5).toFixed(5)}, ${Number(tint[1] ?? 0.62).toFixed(5)}, ${Number(tint[2] ?? 0.72).toFixed(5)}) * 2.0, ${tintAmount.toFixed(5)});
|
||||
color = mix(sourceColor.rgb, max(color, vec3<f32>(0.0)), ${mixAmount.toFixed(5)});
|
||||
return vec4<f32>(color, sourceColor.a);`;
|
||||
}
|
||||
if (node.typeId === "yawn.rg.post.vignette") {
|
||||
const amount = Number(value(node, "amount", 0.35));
|
||||
return `
|
||||
let sourceColor = textureSample(source, sourceSampler, input.uv);
|
||||
let centered = input.uv * 2.0 - 1.0;
|
||||
let vignette = 1.0 - smoothstep(0.35, 1.35, dot(centered, centered)) * ${amount.toFixed(5)};
|
||||
return vec4<f32>(sourceColor.rgb * vignette, sourceColor.a);`;
|
||||
}
|
||||
if (node.typeId === "yawn.rg.post.grayscale")
|
||||
return `let sourceColor = textureSample(source, sourceSampler, input.uv); let luminance = dot(sourceColor.rgb, vec3<f32>(0.2126, 0.7152, 0.0722)); return vec4<f32>(vec3<f32>(luminance), sourceColor.a);`;
|
||||
return `let sourceColor = textureSample(source, sourceSampler, input.uv); return vec4<f32>(vec3<f32>(1.0) - sourceColor.rgb, sourceColor.a);`;
|
||||
}
|
||||
|
||||
function effectShader(node: any): string {
|
||||
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> {
|
||||
${effectExpression(node)}
|
||||
}`;
|
||||
}
|
||||
|
||||
function applyEffects(graph: PlainObject, effects: any[]): void {
|
||||
if (!effects.length) return;
|
||||
const present = graph.passes?.find(
|
||||
(pass: any) =>
|
||||
pass.type === "render" &&
|
||||
pass.color?.some((attachment: any) => attachment.resource === "canvas"),
|
||||
);
|
||||
if (!present)
|
||||
throw new Error("Base render graph has no canvas presentation pass");
|
||||
const sourceBinding = present.bindings?.find(
|
||||
(binding: any) => binding.group === 0 && binding.binding === 0,
|
||||
);
|
||||
const samplerBinding = present.bindings?.find(
|
||||
(binding: any) => binding.group === 0 && binding.binding === 1,
|
||||
);
|
||||
if (!sourceBinding || !samplerBinding)
|
||||
throw new Error("Canvas presentation pass has no sampled source");
|
||||
const textures = graph.resources?.textures;
|
||||
const pipelines = graph.pipelines?.render;
|
||||
if (!Array.isArray(textures) || !Array.isArray(pipelines))
|
||||
throw new Error("Base render graph cannot host post-processing stages");
|
||||
const sourceTexture = textures.find(
|
||||
(texture: any) => texture.id === sourceBinding.resource,
|
||||
);
|
||||
if (!sourceTexture?.format)
|
||||
throw new Error(
|
||||
`Presentation texture ${sourceBinding.resource} is not declared`,
|
||||
);
|
||||
|
||||
let input = sourceBinding.resource;
|
||||
let after = Array.isArray(present.after) ? [...present.after] : [];
|
||||
for (const [index, node] of effects.entries()) {
|
||||
const suffix = `${index}-${String(node.id).replace(/[^a-z0-9-]/gi, "-")}`;
|
||||
const final = index === effects.length - 1;
|
||||
const output = final ? "canvas" : `rg-effect-${suffix}`;
|
||||
const pipeline = `rg-effect-pipeline-${suffix}`;
|
||||
const pass = `rg-effect-pass-${suffix}`;
|
||||
if (!final)
|
||||
textures.push({
|
||||
id: output,
|
||||
format: sourceTexture.format,
|
||||
size: ["canvas", "canvas", 1],
|
||||
usage: ["render", "sampled"],
|
||||
transient: true,
|
||||
});
|
||||
pipelines.push({
|
||||
id: pipeline,
|
||||
code: effectShader(node),
|
||||
vertex: { entry: "vertex", buffers: [] },
|
||||
fragment: {
|
||||
entry: "fragment",
|
||||
targets: [{ format: final ? "canvas" : sourceTexture.format }],
|
||||
},
|
||||
});
|
||||
graph.passes.push({
|
||||
id: pass,
|
||||
type: "render",
|
||||
pipeline,
|
||||
after,
|
||||
bindings: [
|
||||
{ group: 0, binding: 0, resource: input },
|
||||
{ group: 0, binding: 1, resource: samplerBinding.resource },
|
||||
],
|
||||
color: [{ resource: output, clear: [0, 0, 0, 1] }],
|
||||
draw: { vertices: 3 },
|
||||
});
|
||||
input = output;
|
||||
after = [pass];
|
||||
}
|
||||
graph.passes = graph.passes.filter((pass: PlainObject) => pass !== present);
|
||||
}
|
||||
|
||||
function routeDebugModeToCanvas(graph: PlainObject, mode: string): void {
|
||||
const present = graph.passes?.find(
|
||||
(pass: PlainObject) =>
|
||||
pass.type === "render" &&
|
||||
pass.color?.some(
|
||||
(attachment: PlainObject) => attachment.resource === "canvas",
|
||||
),
|
||||
);
|
||||
const source = present?.bindings?.find(
|
||||
(binding: PlainObject) => binding.group === 0 && binding.binding === 0,
|
||||
)?.resource;
|
||||
if (!present || typeof source !== "string")
|
||||
throw new Error("Base render graph has no sampled presentation pass");
|
||||
const producers = graph.passes.filter(
|
||||
(pass: PlainObject) =>
|
||||
pass.type === "render" &&
|
||||
pass.color?.some(
|
||||
(attachment: PlainObject) => attachment.resource === source,
|
||||
) &&
|
||||
String(pass.pipeline).startsWith(`rg-${mode}-`),
|
||||
);
|
||||
if (!producers.length)
|
||||
throw new Error(`Debug mode ${mode} has no render passes`);
|
||||
const pipelines = new Map(
|
||||
(graph.pipelines?.render ?? []).map((pipeline: PlainObject) => [
|
||||
pipeline.id,
|
||||
pipeline,
|
||||
]),
|
||||
);
|
||||
for (const pass of producers) {
|
||||
for (const attachment of pass.color)
|
||||
if (attachment.resource === source) attachment.resource = "canvas";
|
||||
const pipeline = pipelines.get(pass.pipeline);
|
||||
const targets = pipeline?.fragment?.targets;
|
||||
if (!Array.isArray(targets) || !targets.length)
|
||||
throw new Error(`Debug pipeline ${String(pass.pipeline)} has no target`);
|
||||
targets[0].format = "canvas";
|
||||
}
|
||||
graph.passes = graph.passes.filter((pass: PlainObject) => pass !== present);
|
||||
}
|
||||
|
||||
function pruneDeclarations(graph: PlainObject): void {
|
||||
const usedResources = new Set(
|
||||
(graph.passes ?? []).flatMap((pass: PlainObject) => passResources(pass)),
|
||||
);
|
||||
for (const kind of ["buffers", "textures", "samplers"])
|
||||
if (Array.isArray(graph.resources?.[kind]))
|
||||
graph.resources[kind] = graph.resources[kind].filter(
|
||||
(resource: PlainObject) => usedResources.has(resource.id),
|
||||
);
|
||||
const renderPipelines = new Set(
|
||||
(graph.passes ?? [])
|
||||
.filter((pass: PlainObject) => pass.type === "render")
|
||||
.map((pass: PlainObject) => pass.pipeline),
|
||||
);
|
||||
const computePipelines = new Set(
|
||||
(graph.passes ?? [])
|
||||
.filter((pass: PlainObject) => pass.type === "compute")
|
||||
.map((pass: PlainObject) => pass.pipeline),
|
||||
);
|
||||
if (Array.isArray(graph.pipelines?.render))
|
||||
graph.pipelines.render = graph.pipelines.render.filter(
|
||||
(pipeline: PlainObject) => renderPipelines.has(pipeline.id),
|
||||
);
|
||||
if (Array.isArray(graph.pipelines?.compute))
|
||||
graph.pipelines.compute = graph.pipelines.compute.filter(
|
||||
(pipeline: PlainObject) => computePipelines.has(pipeline.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an FxNode loadout into the concrete graph that Yawn will compile.
|
||||
*/
|
||||
export function buildFxNodeRenderGraph(
|
||||
layout: GraphSource,
|
||||
baseGraph: object,
|
||||
): object {
|
||||
if (!plainRecord(baseGraph))
|
||||
throw new TypeError("Base render graph must be plain data");
|
||||
if (isPhysicalRenderGraph(layout)) return reconstructRenderGraph(layout);
|
||||
const graph = structuredClone(baseGraph) as PlainObject;
|
||||
const chain = activeChain(layout);
|
||||
const mode = chain.findLast((node) => modeTypes.has(String(node.typeId)));
|
||||
if (mode) applyDebugMode(graph, mode);
|
||||
const effects = chain.filter((node) => effectTypes.has(String(node.typeId)));
|
||||
if (mode && mode.typeId !== "yawn.rg.mode.beauty" && effects.length === 0)
|
||||
routeDebugModeToCanvas(
|
||||
graph,
|
||||
String(mode.typeId).slice("yawn.rg.mode.".length),
|
||||
);
|
||||
else applyEffects(graph, effects);
|
||||
pruneDeclarations(graph);
|
||||
const suffix = chain
|
||||
.filter(
|
||||
(node) =>
|
||||
modeTypes.has(String(node.typeId)) ||
|
||||
effectTypes.has(String(node.typeId)),
|
||||
)
|
||||
.map((node) => String(node.typeId).split(".").at(-1))
|
||||
.map((stage) => (stage === "beauty" ? "standard" : stage))
|
||||
.join("-");
|
||||
graph.id = `${String(graph.id || "scene")}-fxnode-${suffix || "standard"}`;
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles an FxNode loadout over a scene-authored graph and returns the exact
|
||||
* S-expression consumed by `YawnCore.compileGraph`.
|
||||
*/
|
||||
export function compileFxNodeRenderGraph(
|
||||
layout: GraphSource,
|
||||
baseGraph: object,
|
||||
): string {
|
||||
return encodeRenderGraph(buildFxNodeRenderGraph(layout, baseGraph));
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import {
|
||||
createFxNode,
|
||||
type FxNode,
|
||||
type FxNodeView,
|
||||
type GraphLayoutV2,
|
||||
type ParameterValue,
|
||||
type Vec2,
|
||||
} from "fxnode";
|
||||
import { renderGraphComposition, renderGraphPresets } from "./catalog";
|
||||
|
||||
export interface RenderGraphAddNodeRequest {
|
||||
readonly viewPosition: Vec2;
|
||||
add(typeId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RenderGraphEditorOptions {
|
||||
readonly canvas: HTMLCanvasElement;
|
||||
readonly initialLayout?: GraphLayoutV2;
|
||||
readonly workerUrl: string | URL;
|
||||
readonly onChange?: () => void;
|
||||
readonly onSelectionChange?: (nodeIds: readonly string[]) => void;
|
||||
readonly onAddNodeMenu?: (request: RenderGraphAddNodeRequest) => void;
|
||||
readonly onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface RenderGraphEditor {
|
||||
load(layout: GraphLayoutV2): Promise<void>;
|
||||
save(): Promise<GraphLayoutV2>;
|
||||
setParameter(
|
||||
nodeId: string,
|
||||
key: string,
|
||||
value: ParameterValue,
|
||||
): Promise<void>;
|
||||
syncViewport(): Promise<void>;
|
||||
whenRendered(): Promise<void>;
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
|
||||
function viewport(canvas: HTMLCanvasElement) {
|
||||
const dpr = Math.min(
|
||||
4,
|
||||
Math.max(1, canvas.ownerDocument.defaultView?.devicePixelRatio || 1),
|
||||
);
|
||||
const maximumDimension = Math.floor(8192 / dpr);
|
||||
const maximumPixels = Math.floor(16_777_216 / (dpr * dpr));
|
||||
const width = Math.min(maximumDimension, Math.max(1, canvas.clientWidth));
|
||||
return {
|
||||
width,
|
||||
height: Math.min(
|
||||
maximumDimension,
|
||||
Math.floor(maximumPixels / width),
|
||||
Math.max(1, canvas.clientHeight),
|
||||
),
|
||||
dpr,
|
||||
};
|
||||
}
|
||||
|
||||
function sizeCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
value: ReturnType<typeof viewport>,
|
||||
) {
|
||||
const width = Math.max(1, Math.round(value.width * value.dpr));
|
||||
const height = Math.max(1, Math.round(value.height * value.dpr));
|
||||
if (canvas.width !== width) canvas.width = width;
|
||||
if (canvas.height !== height) canvas.height = height;
|
||||
}
|
||||
|
||||
function position(canvas: HTMLCanvasElement, event: MouseEvent) {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
return { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
|
||||
}
|
||||
|
||||
function modifiers(event: MouseEvent | KeyboardEvent) {
|
||||
return {
|
||||
alt: event.altKey,
|
||||
control: event.ctrlKey,
|
||||
meta: event.metaKey,
|
||||
shift: event.shiftKey,
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates the worker-owned FxNode render-graph editor used by the playground. */
|
||||
export async function createRenderGraphEditor({
|
||||
canvas,
|
||||
initialLayout = renderGraphPresets[0]!.layout,
|
||||
workerUrl,
|
||||
onChange = () => {},
|
||||
onSelectionChange = () => {},
|
||||
onAddNodeMenu = () => {},
|
||||
onError = console.error,
|
||||
}: RenderGraphEditorOptions): Promise<RenderGraphEditor> {
|
||||
let root: FxNode | undefined;
|
||||
let view: FxNodeView | undefined;
|
||||
let destroyed = false;
|
||||
let menuPending = false;
|
||||
let resizeWork = Promise.resolve();
|
||||
const captured = new Set<number>();
|
||||
const listeners: Array<
|
||||
readonly [EventTarget, string, EventListener, AddEventListenerOptions?]
|
||||
> = [];
|
||||
const subscriptions: (() => void)[] = [];
|
||||
const listen = (
|
||||
target: EventTarget,
|
||||
type: string,
|
||||
listener: EventListener,
|
||||
options?: AddEventListenerOptions,
|
||||
) => {
|
||||
target.addEventListener(type, listener, options);
|
||||
listeners.push([target, type, listener, options]);
|
||||
};
|
||||
const report = (error: unknown) => {
|
||||
try {
|
||||
onError(error);
|
||||
} catch (nested) {
|
||||
console.error("Render graph editor error callback failed", nested);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
root = await createFxNode({
|
||||
applicationId: renderGraphComposition.id,
|
||||
applicationVersion: renderGraphComposition.version,
|
||||
resources: {},
|
||||
workerUrl,
|
||||
});
|
||||
await root.loadComposition(renderGraphComposition);
|
||||
await root.load(initialLayout);
|
||||
const initialViewport = viewport(canvas);
|
||||
sizeCanvas(canvas, initialViewport);
|
||||
const initialPhysical =
|
||||
initialLayout.metadata.schema === "yawn.render-graph/v1";
|
||||
view = await root.attachView({
|
||||
canvas,
|
||||
viewport: initialViewport,
|
||||
initialCamera: {
|
||||
center: initialPhysical ? { x: 320, y: 300 } : { x: 0, y: 10 },
|
||||
zoom:
|
||||
(canvas.ownerDocument.defaultView?.innerWidth ?? 1024) <= 800
|
||||
? initialPhysical
|
||||
? 0.56
|
||||
: 0.9
|
||||
: initialPhysical
|
||||
? 0.72
|
||||
: 1.1,
|
||||
},
|
||||
});
|
||||
canvas.tabIndex = 0;
|
||||
canvas.style.touchAction = "none";
|
||||
const activeView = view;
|
||||
const fitGraph = async () => {
|
||||
activeView.feedInput({
|
||||
kind: "key",
|
||||
phase: "down",
|
||||
key: "Home",
|
||||
code: "Home",
|
||||
repeat: false,
|
||||
modifiers: { alt: false, control: false, meta: false, shift: false },
|
||||
});
|
||||
await activeView.whenRendered();
|
||||
};
|
||||
|
||||
const pointer = (event: PointerEvent) => {
|
||||
if (destroyed) return;
|
||||
const phase =
|
||||
event.type === "pointerdown"
|
||||
? "down"
|
||||
: event.type === "pointermove"
|
||||
? "move"
|
||||
: event.type === "pointerup"
|
||||
? "up"
|
||||
: "cancel";
|
||||
if (phase === "down") {
|
||||
canvas.focus();
|
||||
menuPending = event.button === 2 && !event.ctrlKey;
|
||||
try {
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
captured.add(event.pointerId);
|
||||
} catch {
|
||||
// Pointer capture is optional on detached/test canvases.
|
||||
}
|
||||
}
|
||||
activeView.feedInput({
|
||||
kind: "pointer",
|
||||
phase,
|
||||
pointerId: event.pointerId,
|
||||
pointerType: event.pointerType,
|
||||
position: position(canvas, event),
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
modifiers: modifiers(event),
|
||||
});
|
||||
if (
|
||||
(phase === "up" || phase === "cancel") &&
|
||||
captured.delete(event.pointerId)
|
||||
) {
|
||||
try {
|
||||
if (canvas.hasPointerCapture(event.pointerId))
|
||||
canvas.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Capture may already have been released by the browser.
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const type of [
|
||||
"pointerdown",
|
||||
"pointermove",
|
||||
"pointerup",
|
||||
"pointercancel",
|
||||
])
|
||||
listen(canvas, type, pointer as EventListener);
|
||||
listen(canvas, "contextmenu", ((event: Event) =>
|
||||
event.preventDefault()) as EventListener);
|
||||
listen(
|
||||
canvas,
|
||||
"wheel",
|
||||
((event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
menuPending = false;
|
||||
const scale =
|
||||
event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
||||
? 16
|
||||
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
||||
? Math.max(1, canvas.clientHeight)
|
||||
: 1;
|
||||
activeView.feedInput({
|
||||
kind: "wheel",
|
||||
position: position(canvas, event),
|
||||
delta: { x: event.deltaX * scale, y: event.deltaY * scale },
|
||||
modifiers: modifiers(event),
|
||||
});
|
||||
}) as EventListener,
|
||||
{ passive: false },
|
||||
);
|
||||
for (const type of ["keydown", "keyup"])
|
||||
listen(canvas, type, ((event: KeyboardEvent) => {
|
||||
menuPending = false;
|
||||
activeView.feedInput({
|
||||
kind: "key",
|
||||
phase: event.type === "keydown" ? "down" : "up",
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
repeat: event.repeat,
|
||||
modifiers: modifiers(event),
|
||||
});
|
||||
}) as EventListener);
|
||||
for (const type of ["focus", "blur"])
|
||||
listen(canvas, type, (() =>
|
||||
activeView.feedInput({
|
||||
kind: "focus",
|
||||
phase: type === "focus" ? "focus" : "blur",
|
||||
})) as EventListener);
|
||||
|
||||
const syncViewport = async () => {
|
||||
if (destroyed) return;
|
||||
const next = viewport(canvas);
|
||||
resizeWork = resizeWork.then(async () => {
|
||||
await activeView.setViewport(next);
|
||||
sizeCanvas(canvas, next);
|
||||
});
|
||||
await resizeWork;
|
||||
};
|
||||
const observer = new ResizeObserver(
|
||||
() => void syncViewport().catch(report),
|
||||
);
|
||||
observer.observe(canvas);
|
||||
subscriptions.push(() => observer.disconnect());
|
||||
subscriptions.push(
|
||||
activeView.onHostRequests((request) => {
|
||||
if (request.kind !== "add-node-menu" || !menuPending) return;
|
||||
menuPending = false;
|
||||
onAddNodeMenu({
|
||||
viewPosition: request.viewPosition,
|
||||
async add(typeId) {
|
||||
await activeView.addNode({
|
||||
typeId,
|
||||
viewPosition: request.viewPosition,
|
||||
});
|
||||
await activeView.whenRendered();
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
subscriptions.push(root.onMutations(() => onChange()));
|
||||
let selectionSignature = "";
|
||||
const publishSelection = () => {
|
||||
const nodeIds = activeView.getHostSnapshot().selection.nodeIds;
|
||||
const signature = JSON.stringify(nodeIds);
|
||||
if (signature === selectionSignature) return;
|
||||
selectionSignature = signature;
|
||||
onSelectionChange([...nodeIds]);
|
||||
};
|
||||
subscriptions.push(activeView.subscribeHost(publishSelection));
|
||||
publishSelection();
|
||||
if (initialPhysical) await activeView.whenRendered();
|
||||
else await fitGraph();
|
||||
|
||||
return {
|
||||
async load(layout) {
|
||||
if (destroyed || !root)
|
||||
throw new Error("Render graph editor is closed");
|
||||
await root.load(layout);
|
||||
if (layout.metadata.schema === "yawn.render-graph/v1")
|
||||
await activeView.whenRendered();
|
||||
else await fitGraph();
|
||||
},
|
||||
async save() {
|
||||
if (destroyed || !root)
|
||||
throw new Error("Render graph editor is closed");
|
||||
return root.save();
|
||||
},
|
||||
async setParameter(nodeId, key, value) {
|
||||
if (destroyed || !root)
|
||||
throw new Error("Render graph editor is closed");
|
||||
await root.dispatch({
|
||||
type: "node.parameter",
|
||||
id: nodeId as never,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
},
|
||||
syncViewport,
|
||||
whenRendered: () => activeView.whenRendered(),
|
||||
async destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
for (const unsubscribe of subscriptions) unsubscribe();
|
||||
for (const [target, type, listener, options] of listeners)
|
||||
target.removeEventListener(type, listener, options);
|
||||
for (const pointerId of captured) {
|
||||
try {
|
||||
if (canvas.hasPointerCapture(pointerId))
|
||||
canvas.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
// The canvas may already be detached.
|
||||
}
|
||||
}
|
||||
captured.clear();
|
||||
await activeView.detach().catch(() => undefined);
|
||||
root?.destroy();
|
||||
root = undefined;
|
||||
view = undefined;
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
for (const unsubscribe of subscriptions) unsubscribe();
|
||||
for (const [target, type, listener, options] of listeners)
|
||||
target.removeEventListener(type, listener, options);
|
||||
await view?.detach().catch(() => undefined);
|
||||
root?.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./catalog";
|
||||
export * from "./compiler";
|
||||
export * from "./editor";
|
||||
export * from "./physical";
|
||||
@@ -0,0 +1,688 @@
|
||||
import type {
|
||||
GraphLayoutNodeV1,
|
||||
GraphLayoutV2,
|
||||
GraphLink,
|
||||
GraphSnapshot,
|
||||
JsonValue,
|
||||
ParameterValue,
|
||||
} from "fxnode";
|
||||
import { renderGraphComposition } from "./catalog";
|
||||
|
||||
type PlainObject = Record<string, any>;
|
||||
type GraphSource = GraphLayoutV2 | GraphSnapshot;
|
||||
|
||||
export const physicalRenderGraphSchema = "yawn.render-graph/v1";
|
||||
|
||||
const physicalTypes = {
|
||||
frameIn: "yawn.rg.physical.frame-in",
|
||||
frameOut: "yawn.rg.physical.frame-out",
|
||||
buffer: "yawn.rg.physical.buffer",
|
||||
texture: "yawn.rg.physical.texture",
|
||||
sampler: "yawn.rg.physical.sampler",
|
||||
external: "yawn.rg.physical.external",
|
||||
renderPipeline: "yawn.rg.physical.render-pipeline",
|
||||
computePipeline: "yawn.rg.physical.compute-pipeline",
|
||||
renderPass: "yawn.rg.physical.render-pass",
|
||||
computePass: "yawn.rg.physical.compute-pass",
|
||||
} as const;
|
||||
|
||||
function plainRecord(value: unknown): value is PlainObject {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function parameterString(node: any, key: string): string {
|
||||
const parameter = node.parameters?.[key] as ParameterValue | undefined;
|
||||
if (parameter?.kind !== "string")
|
||||
throw new Error(
|
||||
`${String(node.label || node.id)} has no ${key} declaration`,
|
||||
);
|
||||
return parameter.value;
|
||||
}
|
||||
|
||||
function declaration(node: any): PlainObject {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(parameterString(node, "declaration"));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${String(node.label || node.id)} has an invalid physical declaration`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (!plainRecord(parsed))
|
||||
throw new Error(
|
||||
`${String(node.label || node.id)} declaration must be an object`,
|
||||
);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function cloned<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
function withoutResource(value: PlainObject): PlainObject {
|
||||
const result = cloned(value);
|
||||
delete result.resource;
|
||||
return result;
|
||||
}
|
||||
|
||||
function summarizeSize(value: unknown): string {
|
||||
return Array.isArray(value) ? value.join(" × ") : "automatic size";
|
||||
}
|
||||
|
||||
function drawSummary(pass: PlainObject): string {
|
||||
if (pass.type === "compute") {
|
||||
const dispatch = Array.isArray(pass.dispatch)
|
||||
? pass.dispatch.join(" × ")
|
||||
: "1 × 1 × 1";
|
||||
return `${dispatch} workgroups · ${(pass.bindings ?? []).length} bindings`;
|
||||
}
|
||||
const draw = plainRecord(pass.draw) ? pass.draw : {};
|
||||
const count = Number(draw.indices || draw.vertices || 3);
|
||||
const primitive = draw.indices ? "indices" : "vertices";
|
||||
const instances = Number(draw.instances ?? 1);
|
||||
return `${count} ${primitive} × ${instances} · ${(pass.bindings ?? []).length} bindings · ${(pass.color ?? []).length} color`;
|
||||
}
|
||||
|
||||
function layoutNode(
|
||||
id: string,
|
||||
typeId: keyof typeof renderGraphComposition.nodes,
|
||||
label: string,
|
||||
details: string,
|
||||
value: PlainObject,
|
||||
position: { readonly x: number; readonly y: number },
|
||||
code?: string,
|
||||
): GraphLayoutNodeV1 {
|
||||
const definition = renderGraphComposition.nodes[typeId];
|
||||
const parameters = Object.fromEntries(
|
||||
Object.entries(definition.parameters).map(([key, schema]) => [
|
||||
key,
|
||||
cloned(schema.default),
|
||||
]),
|
||||
) as Record<string, ParameterValue>;
|
||||
parameters.details = { kind: "string", value: details };
|
||||
parameters.declaration = { kind: "string", value: JSON.stringify(value) };
|
||||
if (code !== undefined) parameters.code = { kind: "string", value: code };
|
||||
const sockets = Object.entries(definition.sockets).map(([key, socket]) => ({
|
||||
id: `${id}:${key}` as GraphLayoutNodeV1["sockets"][number]["id"],
|
||||
key,
|
||||
label: socket.title,
|
||||
direction: socket.direction,
|
||||
dataType: socket.type,
|
||||
accepts:
|
||||
socket.direction === "input"
|
||||
? cloned(
|
||||
renderGraphComposition.socketTypes[
|
||||
socket.type as keyof typeof renderGraphComposition.socketTypes
|
||||
].acceptsFrom,
|
||||
)
|
||||
: [],
|
||||
maxIncomingLinks: socket.maxIncomingLinks,
|
||||
...(socket.value ? { defaultValue: cloned(socket.value.default) } : {}),
|
||||
visible: socket.visible,
|
||||
}));
|
||||
return {
|
||||
id: id as GraphLayoutNodeV1["id"],
|
||||
typeId,
|
||||
typeVersion: definition.version,
|
||||
position,
|
||||
size: {
|
||||
x: typeId.includes("pass") ? 228 : 210,
|
||||
y: Math.max(98, 47 + definition.ui.length * 24),
|
||||
},
|
||||
label,
|
||||
parameters,
|
||||
sockets,
|
||||
muted: false,
|
||||
collapsed: false,
|
||||
extensions: { physical: true },
|
||||
};
|
||||
}
|
||||
|
||||
function layoutLink(
|
||||
index: number,
|
||||
from: GraphLayoutNodeV1,
|
||||
fromSocket: string,
|
||||
to: GraphLayoutNodeV1,
|
||||
toSocket: string,
|
||||
relation: string,
|
||||
relationIndex = 0,
|
||||
descriptor: PlainObject = {},
|
||||
): GraphLink {
|
||||
return {
|
||||
id: `physical-link-${index}` as GraphLink["id"],
|
||||
fromNodeId: from.id,
|
||||
fromSocketId: `${from.id}:${fromSocket}` as GraphLink["fromSocketId"],
|
||||
toNodeId: to.id,
|
||||
toSocketId: `${to.id}:${toSocket}` as GraphLink["toSocketId"],
|
||||
muted: false,
|
||||
extensions: {
|
||||
relation,
|
||||
index: relationIndex,
|
||||
descriptor: descriptor as JsonValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resetPassRelations(pass: PlainObject): PlainObject {
|
||||
const result = cloned(pass);
|
||||
result.pipeline = "";
|
||||
if (Object.hasOwn(result, "after")) result.after = [];
|
||||
if (Object.hasOwn(result, "bindings")) result.bindings = [];
|
||||
if (Object.hasOwn(result, "color")) result.color = [];
|
||||
if (Object.hasOwn(result, "depth")) result.depth = null;
|
||||
if (Object.hasOwn(result, "vertexBuffers")) result.vertexBuffers = [];
|
||||
if (Object.hasOwn(result, "indexBuffer")) result.indexBuffer = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Expands a concrete Yawn graph into editable physical FxNode declarations. */
|
||||
export function materializeRenderGraph(
|
||||
graphValue: object,
|
||||
preset = "custom",
|
||||
): GraphLayoutV2 {
|
||||
if (!plainRecord(graphValue))
|
||||
throw new TypeError("Render graph must be plain data");
|
||||
const graph = cloned(graphValue);
|
||||
const buffers = Array.isArray(graph.resources?.buffers)
|
||||
? graph.resources.buffers
|
||||
: [];
|
||||
const textures = Array.isArray(graph.resources?.textures)
|
||||
? graph.resources.textures
|
||||
: [];
|
||||
const samplers = Array.isArray(graph.resources?.samplers)
|
||||
? graph.resources.samplers
|
||||
: [];
|
||||
const renderPipelines = Array.isArray(graph.pipelines?.render)
|
||||
? graph.pipelines.render
|
||||
: [];
|
||||
const computePipelines = Array.isArray(graph.pipelines?.compute)
|
||||
? graph.pipelines.compute
|
||||
: [];
|
||||
const passes = Array.isArray(graph.passes) ? graph.passes : [];
|
||||
const nodes: GraphLayoutNodeV1[] = [];
|
||||
const links: GraphLink[] = [];
|
||||
const resources = new Map<string, GraphLayoutNodeV1>();
|
||||
const pipelines = new Map<string, GraphLayoutNodeV1>();
|
||||
const passNodes = new Map<string, GraphLayoutNodeV1>();
|
||||
const passLevels = new Map<string, number>();
|
||||
const levelOf = (pass: PlainObject, visiting = new Set<string>()): number => {
|
||||
const id = String(pass.id);
|
||||
const known = passLevels.get(id);
|
||||
if (known !== undefined) return known;
|
||||
if (visiting.has(id)) return 0;
|
||||
visiting.add(id);
|
||||
const dependencies = (pass.after ?? [])
|
||||
.map((dependency: string) =>
|
||||
passes.find((candidate: PlainObject) => candidate.id === dependency),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.map((dependency: PlainObject) => levelOf(dependency, visiting));
|
||||
visiting.delete(id);
|
||||
const level = dependencies.length ? Math.max(...dependencies) + 1 : 0;
|
||||
passLevels.set(id, level);
|
||||
return level;
|
||||
};
|
||||
passes.forEach((pass: PlainObject) => levelOf(pass));
|
||||
const addUnique = (
|
||||
map: Map<string, GraphLayoutNodeV1>,
|
||||
id: unknown,
|
||||
node: GraphLayoutNodeV1,
|
||||
kind: string,
|
||||
) => {
|
||||
if (typeof id !== "string" || !id)
|
||||
throw new Error(`${kind} requires an ID`);
|
||||
if (map.has(id)) throw new Error(`Duplicate ${kind} ID ${id}`);
|
||||
map.set(id, node);
|
||||
nodes.push(node);
|
||||
};
|
||||
|
||||
const resourceEntries = [
|
||||
...buffers.map((value: PlainObject, index: number) => ({
|
||||
kind: "buffer",
|
||||
value,
|
||||
index,
|
||||
})),
|
||||
...textures.map((value: PlainObject, index: number) => ({
|
||||
kind: "texture",
|
||||
value,
|
||||
index,
|
||||
})),
|
||||
...samplers.map((value: PlainObject, index: number) => ({
|
||||
kind: "sampler",
|
||||
value,
|
||||
index,
|
||||
})),
|
||||
];
|
||||
const resourceLevels = new Map<number, number>();
|
||||
const resourceLevel = (id: string): number => {
|
||||
const levels = passes
|
||||
.filter((pass: PlainObject) =>
|
||||
[
|
||||
...(pass.bindings ?? []),
|
||||
...(pass.color ?? []),
|
||||
...(pass.vertexBuffers ?? []),
|
||||
...(pass.depth ? [pass.depth] : []),
|
||||
...(pass.indexBuffer ? [pass.indexBuffer] : []),
|
||||
].some((item: PlainObject) => item?.resource === id),
|
||||
)
|
||||
.map((pass: PlainObject) => levelOf(pass));
|
||||
return levels.length ? Math.min(...levels) : 0;
|
||||
};
|
||||
const resourcePosition = (id: string) => {
|
||||
const level = resourceLevel(id);
|
||||
const row = resourceLevels.get(level) ?? 0;
|
||||
resourceLevels.set(level, row + 1);
|
||||
return { x: 40 + level * 280, y: -420 - row * 142 };
|
||||
};
|
||||
for (const entry of resourceEntries) {
|
||||
const typeId =
|
||||
physicalTypes[entry.kind as "buffer" | "texture" | "sampler"];
|
||||
const details =
|
||||
entry.kind === "buffer"
|
||||
? `${String(entry.value.array || "rows")} · ${(entry.value.usage ?? []).join(" + ") || "copy"}${entry.value.sync ? ` · ${entry.value.sync} sync` : ""}`
|
||||
: entry.kind === "texture"
|
||||
? `${String(entry.value.format || "format")} · ${summarizeSize(entry.value.size)} · ${(entry.value.usage ?? []).join(" + ") || "texture"}`
|
||||
: `${Object.keys(entry.value.descriptor ?? {}).join(" · ") || "default filtering"}`;
|
||||
const node = layoutNode(
|
||||
`physical-${entry.kind}-${entry.index}`,
|
||||
typeId,
|
||||
`${entry.kind[0]!.toUpperCase()}${entry.kind.slice(1)} · ${String(entry.value.id)}`,
|
||||
details,
|
||||
entry.value,
|
||||
resourcePosition(entry.value.id),
|
||||
);
|
||||
addUnique(resources, entry.value.id, node, "resource");
|
||||
}
|
||||
|
||||
const referencedResources = new Set<string>();
|
||||
for (const pass of passes) {
|
||||
for (const item of [
|
||||
...(pass.bindings ?? []),
|
||||
...(pass.color ?? []),
|
||||
...(pass.vertexBuffers ?? []),
|
||||
...(pass.depth ? [pass.depth] : []),
|
||||
...(pass.indexBuffer ? [pass.indexBuffer] : []),
|
||||
]) {
|
||||
if (typeof item?.resource === "string")
|
||||
referencedResources.add(item.resource);
|
||||
}
|
||||
}
|
||||
let externalIndex = 0;
|
||||
for (const id of referencedResources) {
|
||||
if (resources.has(id)) continue;
|
||||
const node = layoutNode(
|
||||
`physical-external-${externalIndex}`,
|
||||
physicalTypes.external,
|
||||
id === "canvas" ? "Canvas surface" : `External · ${id}`,
|
||||
id === "canvas"
|
||||
? "Current WebGPU swap-chain texture"
|
||||
: "Externally supplied resource",
|
||||
{ id, external: true },
|
||||
resourcePosition(id),
|
||||
);
|
||||
externalIndex++;
|
||||
addUnique(resources, id, node, "resource");
|
||||
}
|
||||
|
||||
const pipelineEntries = [
|
||||
...renderPipelines.map((value: PlainObject, index: number) => ({
|
||||
kind: "render",
|
||||
value,
|
||||
index,
|
||||
})),
|
||||
...computePipelines.map((value: PlainObject, index: number) => ({
|
||||
kind: "compute",
|
||||
value,
|
||||
index,
|
||||
})),
|
||||
];
|
||||
for (const [layoutIndex, entry] of pipelineEntries.entries()) {
|
||||
const value = cloned(entry.value);
|
||||
const code = typeof value.code === "string" ? value.code : "";
|
||||
delete value.code;
|
||||
const details =
|
||||
entry.kind === "render"
|
||||
? `${String(value.vertex?.entry || "vertex")} → ${String(value.fragment?.entry || "fragment")} · ${value.fragment?.targets?.length ?? 0} targets · ${code.split("\n").length} WGSL lines`
|
||||
: `${String(value.entry || "main")} · ${code.split("\n").length} WGSL lines`;
|
||||
const consumerLevels = passes
|
||||
.filter((pass: PlainObject) => pass.pipeline === value.id)
|
||||
.map((pass: PlainObject) => levelOf(pass));
|
||||
const pipelineLevel = consumerLevels.length
|
||||
? Math.min(...consumerLevels)
|
||||
: layoutIndex;
|
||||
const pipelineRow = pipelineEntries
|
||||
.slice(0, layoutIndex)
|
||||
.filter((candidate) =>
|
||||
passes.some(
|
||||
(pass: PlainObject) =>
|
||||
pass.pipeline === candidate.value.id &&
|
||||
levelOf(pass) === pipelineLevel,
|
||||
),
|
||||
).length;
|
||||
const node = layoutNode(
|
||||
`physical-${entry.kind}-pipeline-${entry.index}`,
|
||||
entry.kind === "render"
|
||||
? physicalTypes.renderPipeline
|
||||
: physicalTypes.computePipeline,
|
||||
`${entry.kind === "render" ? "Render" : "Compute"} pipeline · ${String(value.id)}`,
|
||||
details,
|
||||
value,
|
||||
{
|
||||
x: 280 + pipelineLevel * 280,
|
||||
y: 590 + pipelineRow * 142,
|
||||
},
|
||||
code,
|
||||
);
|
||||
addUnique(pipelines, value.id, node, "pipeline");
|
||||
}
|
||||
|
||||
const frameIn = layoutNode(
|
||||
"physical-frame-in",
|
||||
physicalTypes.frameIn,
|
||||
"Frame In",
|
||||
"Resources ready",
|
||||
{ kind: "frame-in" },
|
||||
{ x: 30, y: 280 },
|
||||
);
|
||||
nodes.push(frameIn);
|
||||
|
||||
const passRows = new Map<number, number>();
|
||||
for (const [index, pass] of passes.entries()) {
|
||||
const level = levelOf(pass);
|
||||
const row = passRows.get(level) ?? 0;
|
||||
passRows.set(level, row + 1);
|
||||
const node = layoutNode(
|
||||
`physical-pass-${index}`,
|
||||
pass.type === "compute"
|
||||
? physicalTypes.computePass
|
||||
: physicalTypes.renderPass,
|
||||
`${pass.type === "compute" ? "Compute" : "Render"} pass · ${String(pass.id)}`,
|
||||
drawSummary(pass),
|
||||
resetPassRelations(pass),
|
||||
{ x: 280 + level * 280, y: 280 - row * 300 },
|
||||
);
|
||||
addUnique(passNodes, pass.id, node, "pass");
|
||||
}
|
||||
|
||||
const terminalDependencies = new Set(
|
||||
passes.flatMap((pass: PlainObject) =>
|
||||
Array.isArray(pass.after) ? pass.after : [],
|
||||
),
|
||||
);
|
||||
const maximumLevel = Math.max(0, ...passLevels.values());
|
||||
const frameOut = layoutNode(
|
||||
"physical-frame-out",
|
||||
physicalTypes.frameOut,
|
||||
"Frame Out",
|
||||
"GPU work submitted",
|
||||
{ kind: "frame-out" },
|
||||
{ x: 280 + (maximumLevel + 1) * 280, y: 280 },
|
||||
);
|
||||
nodes.push(frameOut);
|
||||
|
||||
const connect = (
|
||||
from: GraphLayoutNodeV1 | undefined,
|
||||
fromSocket: string,
|
||||
to: GraphLayoutNodeV1,
|
||||
toSocket: string,
|
||||
relation: string,
|
||||
index = 0,
|
||||
descriptor: PlainObject = {},
|
||||
) => {
|
||||
if (!from)
|
||||
throw new Error(
|
||||
`${to.label} references a missing ${relation} declaration`,
|
||||
);
|
||||
links.push(
|
||||
layoutLink(
|
||||
links.length,
|
||||
from,
|
||||
fromSocket,
|
||||
to,
|
||||
toSocket,
|
||||
relation,
|
||||
index,
|
||||
descriptor,
|
||||
),
|
||||
);
|
||||
};
|
||||
for (const pass of passes) {
|
||||
const target = passNodes.get(pass.id)!;
|
||||
const after = Array.isArray(pass.after) ? pass.after : [];
|
||||
if (after.length) {
|
||||
after.forEach((id: string, index: number) =>
|
||||
connect(passNodes.get(id), "pass", target, "after", "after", index),
|
||||
);
|
||||
} else {
|
||||
connect(frameIn, "frame", target, "after", "frame", 0);
|
||||
}
|
||||
connect(
|
||||
pipelines.get(pass.pipeline),
|
||||
"pipeline",
|
||||
target,
|
||||
"pipeline",
|
||||
"pipeline",
|
||||
0,
|
||||
);
|
||||
(pass.bindings ?? []).forEach((item: PlainObject, index: number) =>
|
||||
connect(
|
||||
resources.get(item.resource),
|
||||
"resource",
|
||||
target,
|
||||
"bindings",
|
||||
"binding",
|
||||
index,
|
||||
withoutResource(item),
|
||||
),
|
||||
);
|
||||
(pass.color ?? []).forEach((item: PlainObject, index: number) =>
|
||||
connect(
|
||||
resources.get(item.resource),
|
||||
"resource",
|
||||
target,
|
||||
"color",
|
||||
"color",
|
||||
index,
|
||||
withoutResource(item),
|
||||
),
|
||||
);
|
||||
if (pass.depth)
|
||||
connect(
|
||||
resources.get(pass.depth.resource),
|
||||
"resource",
|
||||
target,
|
||||
"depth",
|
||||
"depth",
|
||||
0,
|
||||
withoutResource(pass.depth),
|
||||
);
|
||||
(pass.vertexBuffers ?? []).forEach((item: PlainObject, index: number) =>
|
||||
connect(
|
||||
resources.get(item.resource),
|
||||
"resource",
|
||||
target,
|
||||
"vertex",
|
||||
"vertex",
|
||||
index,
|
||||
withoutResource(item),
|
||||
),
|
||||
);
|
||||
if (pass.indexBuffer)
|
||||
connect(
|
||||
resources.get(pass.indexBuffer.resource),
|
||||
"resource",
|
||||
target,
|
||||
"index",
|
||||
"index",
|
||||
0,
|
||||
withoutResource(pass.indexBuffer),
|
||||
);
|
||||
if (!terminalDependencies.has(pass.id))
|
||||
connect(target, "pass", frameOut, "after", "frame-out", 0);
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
graphId: `yawn-physical-${preset}` as GraphLayoutV2["graphId"],
|
||||
catalogVersion: renderGraphComposition.version,
|
||||
nodes,
|
||||
links,
|
||||
metadata: {
|
||||
schema: physicalRenderGraphSchema,
|
||||
preset,
|
||||
renderGraphId: String(graph.id || "scene"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isPhysicalRenderGraph(layout: GraphSource): boolean {
|
||||
return layout?.metadata?.schema === physicalRenderGraphSchema;
|
||||
}
|
||||
|
||||
function relationDescriptor(link: GraphLink): PlainObject {
|
||||
return plainRecord(link.extensions?.descriptor)
|
||||
? cloned(link.extensions.descriptor)
|
||||
: {};
|
||||
}
|
||||
|
||||
/** Reconstructs a Yawn graph from physical nodes and their typed relationships. */
|
||||
export function reconstructRenderGraph(layout: GraphSource): PlainObject {
|
||||
if (!isPhysicalRenderGraph(layout))
|
||||
throw new TypeError("FxNode layout is not a physical render graph");
|
||||
const activeNodes = layout.nodes.filter((node: any) => !node.muted);
|
||||
const byNodeId = new Map(
|
||||
activeNodes.map((node: any) => [String(node.id), node]),
|
||||
);
|
||||
const frameIn = activeNodes.filter(
|
||||
(node: any) => node.typeId === physicalTypes.frameIn,
|
||||
);
|
||||
const frameOut = activeNodes.filter(
|
||||
(node: any) => node.typeId === physicalTypes.frameOut,
|
||||
);
|
||||
if (frameIn.length !== 1 || frameOut.length !== 1)
|
||||
throw new Error(
|
||||
"Physical loadout requires exactly one Frame In and Frame Out",
|
||||
);
|
||||
|
||||
const resources = {
|
||||
buffers: [] as PlainObject[],
|
||||
textures: [] as PlainObject[],
|
||||
samplers: [] as PlainObject[],
|
||||
};
|
||||
const pipelines = {
|
||||
render: [] as PlainObject[],
|
||||
compute: [] as PlainObject[],
|
||||
};
|
||||
const passes: PlainObject[] = [];
|
||||
const passByNodeId = new Map<string, PlainObject>();
|
||||
for (const node of activeNodes as any[]) {
|
||||
if (node.typeId === physicalTypes.buffer)
|
||||
resources.buffers.push(declaration(node));
|
||||
else if (node.typeId === physicalTypes.texture)
|
||||
resources.textures.push(declaration(node));
|
||||
else if (node.typeId === physicalTypes.sampler)
|
||||
resources.samplers.push(declaration(node));
|
||||
else if (
|
||||
node.typeId === physicalTypes.renderPipeline ||
|
||||
node.typeId === physicalTypes.computePipeline
|
||||
) {
|
||||
const pipeline = declaration(node);
|
||||
pipeline.code = parameterString(node, "code");
|
||||
pipelines[
|
||||
node.typeId === physicalTypes.renderPipeline ? "render" : "compute"
|
||||
].push(pipeline);
|
||||
} else if (
|
||||
node.typeId === physicalTypes.renderPass ||
|
||||
node.typeId === physicalTypes.computePass
|
||||
) {
|
||||
const pass = declaration(node);
|
||||
if (Array.isArray(pass.after)) pass.after = [];
|
||||
if (Array.isArray(pass.bindings)) pass.bindings = [];
|
||||
if (Array.isArray(pass.color)) pass.color = [];
|
||||
if (Array.isArray(pass.vertexBuffers)) pass.vertexBuffers = [];
|
||||
if (Object.hasOwn(pass, "depth")) pass.depth = null;
|
||||
if (Object.hasOwn(pass, "indexBuffer")) pass.indexBuffer = null;
|
||||
pass.pipeline = "";
|
||||
passes.push(pass);
|
||||
passByNodeId.set(String(node.id), pass);
|
||||
}
|
||||
}
|
||||
|
||||
const sourceId = (node: any): string => {
|
||||
const value = declaration(node).id;
|
||||
if (typeof value !== "string" || !value)
|
||||
throw new Error(`${String(node.label || node.id)} requires an ID`);
|
||||
return value;
|
||||
};
|
||||
const incoming = layout.links
|
||||
.filter(
|
||||
(link) =>
|
||||
!link.muted &&
|
||||
byNodeId.has(String(link.fromNodeId)) &&
|
||||
byNodeId.has(String(link.toNodeId)),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Number(left.extensions?.index ?? 0) -
|
||||
Number(right.extensions?.index ?? 0),
|
||||
);
|
||||
for (const link of incoming) {
|
||||
const source = byNodeId.get(String(link.fromNodeId));
|
||||
const target = byNodeId.get(String(link.toNodeId));
|
||||
const pass = passByNodeId.get(String(link.toNodeId));
|
||||
if (!source || !target || !pass) continue;
|
||||
const socket = target.sockets.find(
|
||||
(candidate: any) => String(candidate.id) === String(link.toSocketId),
|
||||
)?.key;
|
||||
if (socket === "after") {
|
||||
if (
|
||||
source.typeId === physicalTypes.renderPass ||
|
||||
source.typeId === physicalTypes.computePass
|
||||
) {
|
||||
if (!Array.isArray(pass.after)) pass.after = [];
|
||||
pass.after.push(sourceId(source));
|
||||
}
|
||||
} else if (socket === "pipeline") {
|
||||
pass.pipeline = sourceId(source);
|
||||
} else if (socket === "bindings") {
|
||||
if (!Array.isArray(pass.bindings)) pass.bindings = [];
|
||||
const descriptor = relationDescriptor(link);
|
||||
if (!Object.hasOwn(descriptor, "group")) descriptor.group = 0;
|
||||
if (!Object.hasOwn(descriptor, "binding"))
|
||||
descriptor.binding = pass.bindings.length;
|
||||
pass.bindings.push({ ...descriptor, resource: sourceId(source) });
|
||||
} else if (socket === "color") {
|
||||
if (!Array.isArray(pass.color)) pass.color = [];
|
||||
pass.color.push({
|
||||
...relationDescriptor(link),
|
||||
resource: sourceId(source),
|
||||
});
|
||||
} else if (socket === "depth") {
|
||||
pass.depth = { ...relationDescriptor(link), resource: sourceId(source) };
|
||||
} else if (socket === "vertex") {
|
||||
if (!Array.isArray(pass.vertexBuffers)) pass.vertexBuffers = [];
|
||||
const descriptor = relationDescriptor(link);
|
||||
if (!Object.hasOwn(descriptor, "slot"))
|
||||
descriptor.slot = pass.vertexBuffers.length;
|
||||
pass.vertexBuffers.push({ ...descriptor, resource: sourceId(source) });
|
||||
} else if (socket === "index") {
|
||||
pass.indexBuffer = {
|
||||
...relationDescriptor(link),
|
||||
resource: sourceId(source),
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const pass of passes) {
|
||||
if (!pass.pipeline)
|
||||
throw new Error(`Pass ${String(pass.id)} has no pipeline`);
|
||||
}
|
||||
return {
|
||||
id: String(layout.metadata.renderGraphId || "scene"),
|
||||
resources,
|
||||
pipelines,
|
||||
passes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
renderGraphComposition,
|
||||
renderGraphNodeGroups,
|
||||
renderGraphPresets,
|
||||
} from "../src/catalog";
|
||||
import {
|
||||
buildFxNodeRenderGraph,
|
||||
compileFxNodeRenderGraph,
|
||||
encodeRenderGraph,
|
||||
} from "../src/compiler";
|
||||
import {
|
||||
isPhysicalRenderGraph,
|
||||
materializeRenderGraph,
|
||||
reconstructRenderGraph,
|
||||
} from "../src/physical";
|
||||
import { bindFxNodeHeadless } from "../../../vendor/fxnode/src/headless-runtime";
|
||||
import { compileFxNodeComposition } from "../../../vendor/fxnode/src/composition/compile";
|
||||
|
||||
const baseGraph = {
|
||||
id: "scene",
|
||||
resources: {
|
||||
buffers: [
|
||||
{ id: "node-positions", array: "nodePositions", usage: ["storage"] },
|
||||
{ id: "node-rotors", array: "nodeRotors", usage: ["storage"] },
|
||||
{ id: "node-scales", array: "nodeScales", usage: ["storage"] },
|
||||
{ id: "mesh-info", array: "meshInfo", usage: ["storage"] },
|
||||
{ id: "camera-matrices", array: "cameraMatrices", usage: ["storage"] },
|
||||
{ id: "geometry-1-positions", array: "positions", usage: ["vertex"] },
|
||||
{ id: "geometry-1-normals", array: "normals", usage: ["vertex"] },
|
||||
],
|
||||
textures: [
|
||||
{
|
||||
id: "hdr",
|
||||
format: "rgba16float",
|
||||
size: ["canvas", "canvas", 1],
|
||||
usage: ["render", "sampled"],
|
||||
},
|
||||
{
|
||||
id: "depth",
|
||||
format: "depth24plus",
|
||||
size: ["canvas", "canvas", 1],
|
||||
usage: ["render"],
|
||||
},
|
||||
],
|
||||
samplers: [{ id: "linear", descriptor: { magFilter: "linear" } }],
|
||||
},
|
||||
pipelines: {
|
||||
compute: [],
|
||||
render: [
|
||||
{
|
||||
id: "forward-pbr-0",
|
||||
code: "original pbr shader",
|
||||
vertex: {
|
||||
entry: "vertex",
|
||||
buffers: [
|
||||
{
|
||||
arrayStride: 16,
|
||||
attributes: [
|
||||
{ format: "float32x3", offset: 0, shaderLocation: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
arrayStride: 16,
|
||||
attributes: [
|
||||
{ format: "float32x3", offset: 0, shaderLocation: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
arrayStride: 16,
|
||||
attributes: [
|
||||
{ format: "float32x2", offset: 0, shaderLocation: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
fragment: {
|
||||
entry: "fragment",
|
||||
targets: [{ format: "rgba16float" }],
|
||||
},
|
||||
depthStencil: {
|
||||
format: "depth24plus",
|
||||
depth_write_enabled: true,
|
||||
depth_compare: "less",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "present",
|
||||
code: "present shader",
|
||||
vertex: { entry: "vertex", buffers: [] },
|
||||
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
passes: [
|
||||
{
|
||||
id: "forward-1--1",
|
||||
type: "render",
|
||||
pipeline: "forward-pbr-0",
|
||||
bindings: [{ group: 0, binding: 0, resource: "unrelated" }],
|
||||
color: [{ resource: "hdr", clear: [0, 0, 0, 1] }],
|
||||
depth: { resource: "depth", clear: 1 },
|
||||
vertexBuffers: [
|
||||
{ slot: 0, resource: "geometry-1-positions" },
|
||||
{ slot: 1, resource: "geometry-1-normals" },
|
||||
{ slot: 2, resource: "geometry-1-uvs" },
|
||||
],
|
||||
draw: { vertices: 3, instances: 1 },
|
||||
},
|
||||
{
|
||||
id: "present",
|
||||
type: "render",
|
||||
pipeline: "present",
|
||||
after: ["forward-1--1"],
|
||||
bindings: [
|
||||
{ group: 0, binding: 0, resource: "hdr" },
|
||||
{ group: 0, binding: 1, resource: "linear" },
|
||||
],
|
||||
color: [{ resource: "canvas", clear: [0, 0, 0, 1] }],
|
||||
draw: { vertices: 3 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test("catalog exposes grouped, materially rich render graph nodes", () => {
|
||||
assert.equal(renderGraphNodeGroups.length, 9);
|
||||
assert.ok(renderGraphNodeGroups.flatMap((group) => group.items).length >= 32);
|
||||
for (const item of renderGraphNodeGroups.flatMap((group) => group.items))
|
||||
assert.ok(renderGraphComposition.nodes[item.typeId], item.typeId);
|
||||
assert.doesNotThrow(() => compileFxNodeComposition(renderGraphComposition));
|
||||
});
|
||||
|
||||
test("prepared intent expands into the complete physical render graph", () => {
|
||||
const standard = renderGraphPresets.find((preset) => preset.id === "beauty")!;
|
||||
const concrete = buildFxNodeRenderGraph(standard.layout, baseGraph);
|
||||
const layout = materializeRenderGraph(concrete, "beauty");
|
||||
assert.equal(isPhysicalRenderGraph(layout), true);
|
||||
assert.ok(layout.nodes.length >= 14);
|
||||
for (const typeId of [
|
||||
"yawn.rg.physical.frame-in",
|
||||
"yawn.rg.physical.frame-out",
|
||||
"yawn.rg.physical.buffer",
|
||||
"yawn.rg.physical.texture",
|
||||
"yawn.rg.physical.sampler",
|
||||
"yawn.rg.physical.render-pipeline",
|
||||
"yawn.rg.physical.render-pass",
|
||||
]) {
|
||||
assert.ok(
|
||||
layout.nodes.some((node) => node.typeId === typeId),
|
||||
typeId,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
layout.links.some((link) => link.extensions.relation === "pipeline"),
|
||||
);
|
||||
assert.ok(
|
||||
layout.links.some((link) => link.extensions.relation === "binding"),
|
||||
);
|
||||
assert.ok(layout.links.some((link) => link.extensions.relation === "color"));
|
||||
assert.ok(layout.links.some((link) => link.extensions.relation === "depth"));
|
||||
assert.ok(layout.links.some((link) => link.extensions.relation === "vertex"));
|
||||
assert.deepEqual(reconstructRenderGraph(layout), concrete);
|
||||
});
|
||||
|
||||
test("physical pipeline shader edits compile into the S-expression", () => {
|
||||
const concrete = buildFxNodeRenderGraph(
|
||||
renderGraphPresets[0]!.layout,
|
||||
baseGraph,
|
||||
);
|
||||
const layout = structuredClone(materializeRenderGraph(concrete, "beauty"));
|
||||
const pipeline = layout.nodes.find(
|
||||
(node) =>
|
||||
node.typeId === "yawn.rg.physical.render-pipeline" &&
|
||||
node.label.includes("forward-pbr-0"),
|
||||
)!;
|
||||
pipeline.parameters.code = {
|
||||
kind: "string",
|
||||
value:
|
||||
"@fragment fn edited_shader() -> @location(0) vec4<f32> { return vec4<f32>(0.25); }",
|
||||
};
|
||||
const serialized = compileFxNodeRenderGraph(layout, baseGraph);
|
||||
assert.match(serialized, /edited_shader/u);
|
||||
assert.doesNotMatch(serialized, /original pbr shader/u);
|
||||
});
|
||||
|
||||
test("Color Grade exposes Blender-style grading modes and migrates version 1 nodes", () => {
|
||||
const definition = renderGraphComposition.nodes["yawn.rg.post.color-grade"];
|
||||
assert.equal(definition.version, 2);
|
||||
assert.deepEqual(
|
||||
definition.ui
|
||||
.filter((row) => row.kind === "widget" && row.widget === "grading-wheels")
|
||||
.map((row) => row.bindings.map((binding) => binding.title)),
|
||||
[
|
||||
["Lift", "Gamma", "Gain"],
|
||||
["Offset", "Power", "Slope"],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(definition.parameters.mode.enum, [
|
||||
"Lift/Gamma/Gain",
|
||||
"Offset/Power/Slope",
|
||||
"White Point",
|
||||
]);
|
||||
|
||||
const legacy = structuredClone(
|
||||
renderGraphPresets.find((preset) => preset.id === "cinematic")!.layout,
|
||||
) as any;
|
||||
legacy.catalogVersion = 1;
|
||||
const grade = legacy.nodes.find((node: any) => node.id === "grade");
|
||||
grade.typeVersion = 1;
|
||||
grade.parameters = Object.fromEntries(
|
||||
["exposure", "saturation", "tint", "tintAmount"].map((key) => [
|
||||
key,
|
||||
grade.parameters[key],
|
||||
]),
|
||||
);
|
||||
const runtime = bindFxNodeHeadless(
|
||||
compileFxNodeComposition(renderGraphComposition),
|
||||
);
|
||||
const decoded = runtime.decodeGraphDocument(legacy);
|
||||
assert.equal(decoded.ok, true);
|
||||
if (!decoded.ok) return;
|
||||
const migrated = decoded.value.nodes.grade!;
|
||||
assert.equal(migrated.known, true);
|
||||
assert.equal(migrated.typeVersion, 2);
|
||||
assert.equal(migrated.parameters.mode.value, "Lift/Gamma/Gain");
|
||||
assert.equal(migrated.parameters.contrast.value, 1);
|
||||
assert.equal(migrated.parameters.outputTemperature.value, 6500);
|
||||
});
|
||||
|
||||
test("prepared loadouts are connected to one Present output", () => {
|
||||
assert.deepEqual(
|
||||
renderGraphPresets.map((preset) => preset.id),
|
||||
["beauty", "normals", "depth", "clay", "cinematic", "blueprint"],
|
||||
);
|
||||
assert.equal(renderGraphPresets[0]!.name, "Standard");
|
||||
for (const preset of renderGraphPresets) {
|
||||
assert.equal(
|
||||
preset.layout.nodes.filter(
|
||||
(node) => node.typeId === "yawn.rg.output.present",
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(preset.layout.links.length, preset.layout.nodes.length - 1);
|
||||
const left = Math.min(
|
||||
...preset.layout.nodes.map((node) => node.position.x),
|
||||
);
|
||||
const right = Math.max(
|
||||
...preset.layout.nodes.map((node) => node.position.x + node.size.x),
|
||||
);
|
||||
assert.ok(right - left <= 650, `${preset.name} is too wide for the editor`);
|
||||
}
|
||||
});
|
||||
|
||||
test("wire encoder is stable and rejects unsupported numbers", () => {
|
||||
assert.equal(
|
||||
encodeRenderGraph({ z: [true, "x"], a: 1 }),
|
||||
'(yawn-graph 1 (object (field "a" 1) (field "z" (array true "x"))))',
|
||||
);
|
||||
assert.throws(() => encodeRenderGraph({ value: Number.NaN }), /finite/u);
|
||||
});
|
||||
|
||||
test("normals loadout replaces scene forward shading without mutating the base", () => {
|
||||
const normals = renderGraphPresets.find((preset) => preset.id === "normals")!;
|
||||
const graph = buildFxNodeRenderGraph(normals.layout, baseGraph) as any;
|
||||
const serialized = encodeRenderGraph(graph);
|
||||
assert.match(serialized, /scene-fxnode-normals/u);
|
||||
assert.match(serialized, /rg-normals-detailed/u);
|
||||
assert.match(serialized, /normalize\(input\.normal\) \* 0\.5 \+ 0\.5/u);
|
||||
assert.match(serialized, /camera-matrices/u);
|
||||
assert.deepEqual(
|
||||
graph.passes.map((pass: any) => pass.id),
|
||||
["forward-1--1"],
|
||||
);
|
||||
assert.deepEqual(graph.passes[0].color, [
|
||||
{ resource: "canvas", clear: [0, 0, 0, 1] },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
graph.pipelines.render.map((pipeline: any) => pipeline.id),
|
||||
["rg-normals-detailed"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
graph.resources.textures.map((texture: any) => texture.id),
|
||||
["depth"],
|
||||
);
|
||||
assert.deepEqual(graph.resources.samplers, []);
|
||||
assert.equal(baseGraph.passes[0].pipeline, "forward-pbr-0");
|
||||
assert.equal(baseGraph.pipelines.render.length, 2);
|
||||
});
|
||||
|
||||
test("debug loadouts remove detached clustered lighting work", () => {
|
||||
const graph = structuredClone(baseGraph) as any;
|
||||
graph.resources.buffers.push(
|
||||
{ id: "point-lights", array: "pointLights", usage: ["storage"] },
|
||||
{ id: "clusters", array: "clusters", usage: ["storage"] },
|
||||
);
|
||||
graph.pipelines.compute.push({
|
||||
id: "cluster-lights",
|
||||
code: "@compute @workgroup_size(1) fn main() {}",
|
||||
entry: "main",
|
||||
});
|
||||
graph.passes.unshift({
|
||||
id: "cluster-lights",
|
||||
type: "compute",
|
||||
pipeline: "cluster-lights",
|
||||
bindings: [
|
||||
{ group: 0, binding: 0, resource: "point-lights" },
|
||||
{ group: 0, binding: 1, resource: "clusters" },
|
||||
],
|
||||
dispatch: [1, 1, 1],
|
||||
});
|
||||
graph.passes[1].after = ["cluster-lights"];
|
||||
const normals = renderGraphPresets.find((preset) => preset.id === "normals")!;
|
||||
const result = buildFxNodeRenderGraph(normals.layout, graph) as any;
|
||||
assert.equal(
|
||||
result.passes.some((pass: any) => pass.type === "compute"),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(result.pipelines.compute, []);
|
||||
assert.equal(
|
||||
result.resources.buffers.some((buffer: any) =>
|
||||
["point-lights", "clusters"].includes(buffer.id),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("depth loadout uses position-only geometry and renders to canvas", () => {
|
||||
const depth = renderGraphPresets.find((preset) => preset.id === "depth")!;
|
||||
const graph = buildFxNodeRenderGraph(depth.layout, baseGraph) as any;
|
||||
assert.deepEqual(
|
||||
graph.pipelines.render.map((pipeline: any) => pipeline.id),
|
||||
["rg-depth-basic"],
|
||||
);
|
||||
assert.match(graph.pipelines.render[0].code, /0\.2040/u);
|
||||
assert.deepEqual(graph.pipelines.render[0].vertex.buffers.length, 1);
|
||||
assert.deepEqual(graph.passes[0].vertexBuffers, [
|
||||
{ slot: 0, resource: "geometry-1-positions" },
|
||||
]);
|
||||
assert.deepEqual(graph.passes[0].color, [
|
||||
{ resource: "canvas", clear: [0, 0, 0, 1] },
|
||||
]);
|
||||
assert.equal(
|
||||
graph.resources.buffers.some(
|
||||
(buffer: any) => buffer.id === "geometry-1-normals",
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("cinematic loadout inserts ordered sampled post passes", () => {
|
||||
const cinematic = renderGraphPresets.find(
|
||||
(preset) => preset.id === "cinematic",
|
||||
)!;
|
||||
const graph = buildFxNodeRenderGraph(cinematic.layout, baseGraph) as any;
|
||||
const serialized = encodeRenderGraph(graph);
|
||||
assert.match(serialized, /rg-effect-pass-0-bloom/u);
|
||||
assert.match(serialized, /rg-effect-pass-1-grade/u);
|
||||
assert.match(serialized, /rg-effect-pass-2-vignette/u);
|
||||
assert.doesNotMatch(serialized, /\(field "id" "rg-effect-2-vignette"\)/u);
|
||||
assert.doesNotMatch(serialized, /\(field "id" "present"\)/u);
|
||||
assert.deepEqual(
|
||||
graph.passes.find((pass: any) => pass.id === "rg-effect-pass-2-vignette")
|
||||
.color,
|
||||
[{ resource: "canvas", clear: [0, 0, 0, 1] }],
|
||||
);
|
||||
assert.match(serialized, /let liftBalance = vec3<f32>/u);
|
||||
assert.match(serialized, /let gammaBalance = vec3<f32>/u);
|
||||
assert.match(serialized, /let gainBalance = vec3<f32>/u);
|
||||
assert.match(serialized, /color = \(color - vec3<f32>\(0\.18000\)\)/u);
|
||||
});
|
||||
|
||||
test("Color Grade compiles offset and white-point modes into pixel math", () => {
|
||||
const loadout = structuredClone(
|
||||
renderGraphPresets.find((preset) => preset.id === "cinematic")!.layout,
|
||||
) as any;
|
||||
const grade = loadout.nodes.find((node: any) => node.id === "grade");
|
||||
grade.parameters.mode = { kind: "string", value: "Offset/Power/Slope" };
|
||||
grade.parameters.offset = { kind: "number", value: 0.08 };
|
||||
grade.parameters.power = { kind: "number", value: 1.12 };
|
||||
grade.parameters.slope = { kind: "number", value: 0.94 };
|
||||
let serialized = compileFxNodeRenderGraph(loadout, baseGraph);
|
||||
assert.match(serialized, /let offsetBalance = vec3<f32>/u);
|
||||
assert.match(serialized, /let powerBalance = vec3<f32>/u);
|
||||
assert.match(serialized, /color \* slopeBalance \+ offsetBalance/u);
|
||||
|
||||
grade.parameters.mode = { kind: "string", value: "White Point" };
|
||||
grade.parameters.inputTemperature = { kind: "number", value: 7200 };
|
||||
grade.parameters.outputTemperature = { kind: "number", value: 4800 };
|
||||
grade.parameters.outputTint = { kind: "number", value: -0.12 };
|
||||
serialized = compileFxNodeRenderGraph(loadout, baseGraph);
|
||||
assert.match(serialized, /let whitePointAdaptation = vec3<f32>/u);
|
||||
assert.match(serialized, /color \*= whitePointAdaptation/u);
|
||||
});
|
||||
|
||||
test("blueprint loadout converts normals into blue paper and linework", () => {
|
||||
const blueprint = renderGraphPresets.find(
|
||||
(preset) => preset.id === "blueprint",
|
||||
)!;
|
||||
const serialized = compileFxNodeRenderGraph(blueprint.layout, baseGraph);
|
||||
assert.match(serialized, /scene-fxnode-normals-edges/u);
|
||||
assert.match(serialized, /vec3<f32>\(0\.008, 0\.045, 0\.11\)/u);
|
||||
assert.match(serialized, /vec3<f32>\(0\.68, 0\.94, 1\.0\)/u);
|
||||
assert.match(serialized, /fract\(input\.uv \* vec2<f32>\(24\.0, 16\.0\)\)/u);
|
||||
assert.match(serialized, /, line\), 1\.0\)/u);
|
||||
});
|
||||
|
||||
test("compiler rejects a disconnected output", () => {
|
||||
const disconnected = structuredClone(renderGraphPresets[0]!.layout);
|
||||
disconnected.links = [];
|
||||
assert.throws(
|
||||
() => compileFxNodeRenderGraph(disconnected, baseGraph),
|
||||
/not connected to Scene/u,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const directory = fileURLToPath(new URL(".", import.meta.url));
|
||||
const output = await mkdtemp(join(tmpdir(), "yawn-fxnode-rg-tests-"));
|
||||
|
||||
try {
|
||||
const entries = (await readdir(directory))
|
||||
.filter((name) => name.endsWith(".test.ts"))
|
||||
.sort()
|
||||
.map((name) => join(directory, name));
|
||||
await build({
|
||||
entryPoints: entries,
|
||||
outdir: output,
|
||||
entryNames: "[name]",
|
||||
outExtension: { ".js": ".mjs" },
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
sourcemap: "inline",
|
||||
});
|
||||
const tests = entries.map((entry) =>
|
||||
join(output, `${basename(entry, ".ts")}.mjs`),
|
||||
);
|
||||
const result = spawnSync(process.execPath, ["--test", ...tests], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
} finally {
|
||||
await rm(output, { force: true, recursive: true });
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
"description": "Conventional scene handles over Yawn's worker and shared render data",
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "node test/run.mjs",
|
||||
"test:screenshots": "node --test --test-concurrency=1 test/screenshots/run.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@yawn/core": "0.1.0"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export type GraphBuffer = {
|
||||
id: string;
|
||||
array: string;
|
||||
usage?: string[];
|
||||
sync?: "frame" | "loadout";
|
||||
sync?: "frame" | "manual";
|
||||
};
|
||||
|
||||
export type GraphTexture = {
|
||||
|
||||
@@ -2,20 +2,27 @@ 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 const VertexKinds = Object.freeze([
|
||||
"positions",
|
||||
"normals",
|
||||
"tangents",
|
||||
"uvs",
|
||||
"uvs2",
|
||||
"colors",
|
||||
"indices",
|
||||
] as const);
|
||||
export type VertexKind = (typeof VertexKinds)[number];
|
||||
export type VertexStreams = Partial<Record<VertexKind, ArrayLike<number>>>;
|
||||
export type MeshOptions = NodeOptions & {
|
||||
geometryId?: number;
|
||||
material?: PBRMaterial;
|
||||
vertexData?: Partial<Record<VertexKind, ArrayLike<number>>>;
|
||||
vertexData?: VertexStreams;
|
||||
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;
|
||||
@@ -26,18 +33,18 @@ export class Mesh extends Node {
|
||||
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.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);
|
||||
if (options.vertexData)
|
||||
await this.#setVertexData(options.vertexData, false);
|
||||
this.#writeBounds();
|
||||
this.#registered = true;
|
||||
await scene.registerMesh(this);
|
||||
@@ -45,6 +52,14 @@ export class Mesh extends Node {
|
||||
});
|
||||
}
|
||||
|
||||
get vertexCount() {
|
||||
return this.scene.geometry(this.geometryId).vertexCount;
|
||||
}
|
||||
|
||||
get indexCount() {
|
||||
return this.scene.geometry(this.geometryId).indexCount;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -60,7 +75,8 @@ export class Mesh extends Node {
|
||||
}
|
||||
|
||||
set material(value: PBRMaterial) {
|
||||
if (value.scene !== this.scene || value.id < 0) throw new Error("Await a material from the same Scene");
|
||||
if (value.scene !== this.scene || value.id < 0)
|
||||
throw new Error("Await a material from the same Scene");
|
||||
this.scene.markDirty(true);
|
||||
this.scene.array("meshInfo").row(this.id)[1] = value.id;
|
||||
void this.scene.updateRenderGraph().catch(() => undefined);
|
||||
@@ -71,41 +87,52 @@ export class Mesh extends Node {
|
||||
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}`);
|
||||
/** Replaces one stream, or several at once, repacking the geometry a single time. */
|
||||
async setVertexData(
|
||||
kind: VertexKind | VertexStreams,
|
||||
data?: ArrayLike<number>,
|
||||
) {
|
||||
const streams = typeof kind === "string" ? { [kind]: data } : kind;
|
||||
for (const name of Object.keys(streams))
|
||||
if (!VertexKinds.includes(name as VertexKind))
|
||||
throw new TypeError(`Unknown vertex kind: ${name}`);
|
||||
await this.ready;
|
||||
await this.#setVertexData(kind, data, true);
|
||||
await this.#setVertexData(streams, true);
|
||||
return this;
|
||||
}
|
||||
|
||||
async #setVertexData(kind: VertexKind, data: ArrayLike<number>, makeUnique: boolean) {
|
||||
if (makeUnique && this.scene.geometryReferences(this.geometryId) > 1) {
|
||||
async #setVertexData(streams: VertexStreams, makeUnique: boolean) {
|
||||
if (makeUnique && this.scene.geometry(this.geometryId).references > 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.markDirty(true);
|
||||
this.scene.array("meshInfo").row(this.id).set([this.geometryId, this.materialId, this.isVisible ? 1 : 0, 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();
|
||||
await this.scene.setVertexData(this.geometryId, streams, makeUnique);
|
||||
if (streams.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");
|
||||
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");
|
||||
this.scene.markDirty(true);
|
||||
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);
|
||||
}
|
||||
this.scene.markDirty(true);
|
||||
await this.scene.updateRenderGraph();
|
||||
return this;
|
||||
}
|
||||
@@ -120,7 +147,10 @@ export class Mesh extends Node {
|
||||
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]);
|
||||
this.scene
|
||||
.array("bounds")
|
||||
.row(this.id)
|
||||
.set([...minimum, 0, ...maximum, 0]);
|
||||
}
|
||||
|
||||
override async dispose() {
|
||||
@@ -130,7 +160,6 @@ export class Mesh extends Node {
|
||||
this.#registered = false;
|
||||
await this.scene.unregisterMesh(this);
|
||||
}
|
||||
await this.scene.core.deleteRows(`mesh.${this.id}.faceMaterials`);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ export class ArcRotateCamera extends Camera {
|
||||
#controls?: ArcRotateControls;
|
||||
#pointer?: { x: number; y: number; button: number };
|
||||
#frame = 0;
|
||||
#inertiaFrame = 0;
|
||||
#lastFrame = 0;
|
||||
#motion = [0, 0, 0, 0, 0]; // orbit x/y, pan x/y, logarithmic zoom
|
||||
|
||||
constructor(scene: Scene, options: ArcRotateCameraOptions = {}) {
|
||||
super(scene, options);
|
||||
@@ -45,30 +48,45 @@ export class ArcRotateCamera extends Camera {
|
||||
});
|
||||
}
|
||||
|
||||
get alpha() { return this.cameraRow()[13]; }
|
||||
get alpha() {
|
||||
return this.cameraRow()[13];
|
||||
}
|
||||
set alpha(value: number) {
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => { row[13] = value; });
|
||||
this.#updateTransform(() => {
|
||||
row[13] = value;
|
||||
});
|
||||
}
|
||||
get beta() {
|
||||
return this.cameraRow()[14];
|
||||
}
|
||||
get beta() { return this.cameraRow()[14]; }
|
||||
set beta(value: number) {
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => {
|
||||
row[14] = Math.min(Math.PI - 0.001, Math.max(0.001, value));
|
||||
});
|
||||
}
|
||||
get radius() { return this.cameraRow()[15]; }
|
||||
get radius() {
|
||||
return this.cameraRow()[15];
|
||||
}
|
||||
set radius(value: number) {
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => { row[15] = Math.max(0.01, value); });
|
||||
this.#updateTransform(() => {
|
||||
row[15] = Math.max(0.01, value);
|
||||
});
|
||||
}
|
||||
|
||||
get target() { return this.#target; }
|
||||
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");
|
||||
if (value && (value.scene !== this.scene || value.id < 0))
|
||||
throw new Error("Target must be a ready Node in this Scene");
|
||||
this.#target = value;
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => { row[12] = value ? value.id + 1 : 0; });
|
||||
this.#updateTransform(() => {
|
||||
row[12] = value ? value.id + 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
attachControls(controls: ArcRotateControls) {
|
||||
@@ -80,9 +98,12 @@ export class ArcRotateCamera extends Camera {
|
||||
controls.element.addEventListener("pointerup", this.#up);
|
||||
controls.element.addEventListener("pointercancel", this.#up);
|
||||
controls.element.addEventListener("contextmenu", this.#contextMenu);
|
||||
controls.element.addEventListener("wheel", this.#wheel, { passive: false });
|
||||
controls.element.addEventListener("wheel", this.#wheel, {
|
||||
passive: false,
|
||||
});
|
||||
}
|
||||
if (controls.controller) this.#frame = requestAnimationFrame(this.#pollController);
|
||||
if (controls.controller)
|
||||
this.#frame = requestAnimationFrame(this.#pollController);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -96,8 +117,11 @@ export class ArcRotateCamera extends Camera {
|
||||
element.removeEventListener("contextmenu", this.#contextMenu);
|
||||
element.removeEventListener("wheel", this.#wheel);
|
||||
}
|
||||
cancelAnimationFrame(this.#frame);
|
||||
if (this.#frame) cancelAnimationFrame(this.#frame);
|
||||
this.#frame = 0;
|
||||
if (this.#inertiaFrame) cancelAnimationFrame(this.#inertiaFrame);
|
||||
this.#inertiaFrame = 0;
|
||||
this.#motion.fill(0);
|
||||
this.#pointer = undefined;
|
||||
this.#controls = undefined;
|
||||
}
|
||||
@@ -106,11 +130,7 @@ export class ArcRotateCamera extends Camera {
|
||||
const row = this.cameraRow();
|
||||
if (!this.#target) return row.subarray(16, 19);
|
||||
const position = this.#target.position;
|
||||
return [
|
||||
position.x + row[16],
|
||||
position.y + row[17],
|
||||
position.z + row[18],
|
||||
];
|
||||
return [position.x + row[16], position.y + row[17], position.z + row[18]];
|
||||
}
|
||||
|
||||
#updateTransform(change?: () => void) {
|
||||
@@ -130,10 +150,16 @@ export class ArcRotateCamera extends Camera {
|
||||
|
||||
#down = (event: PointerEvent) => {
|
||||
event.preventDefault();
|
||||
this.#pointer = { x: event.clientX, y: event.clientY, button: event.button };
|
||||
this.#pointer = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
button: event.button,
|
||||
};
|
||||
this.#controls?.element.setPointerCapture?.(event.pointerId);
|
||||
};
|
||||
#up = () => { this.#pointer = undefined; };
|
||||
#up = () => {
|
||||
this.#pointer = undefined;
|
||||
};
|
||||
#contextMenu = (event: Event) => event.preventDefault();
|
||||
#move = (event: PointerEvent) => {
|
||||
if (!this.#pointer || !this.#controls) return;
|
||||
@@ -141,12 +167,27 @@ export class ArcRotateCamera extends Camera {
|
||||
const y = event.clientY - this.#pointer.y;
|
||||
this.#pointer.x = event.clientX;
|
||||
this.#pointer.y = event.clientY;
|
||||
if (this.#pointer.button === 2) {
|
||||
const offset = this.#pointer.button === 2 ? 2 : 0;
|
||||
this.#motion[offset] += x;
|
||||
this.#motion[offset + 1] += y;
|
||||
this.#scheduleInertia();
|
||||
};
|
||||
|
||||
#applyDrag(x: number, y: number, pan: boolean) {
|
||||
if (pan) {
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => {
|
||||
const speed = this.#controls?.panSpeed ?? 0.005;
|
||||
const horizontal = -x * speed;
|
||||
const vertical = y * speed;
|
||||
const element = this.#controls!.element;
|
||||
const verticalExtent =
|
||||
this.projection === "orthographic"
|
||||
? this.orthoSize
|
||||
: 2 * this.radius * Math.tan(this.fov / 2);
|
||||
const speed = this.#controls?.panSpeed ?? 1;
|
||||
const horizontal =
|
||||
(-x * verticalExtent * this.aspect * speed) /
|
||||
Math.max(1, element.clientWidth);
|
||||
const vertical =
|
||||
(y * verticalExtent * speed) / Math.max(1, element.clientHeight);
|
||||
const sinAlpha = Math.sin(row[13]);
|
||||
const cosAlpha = Math.cos(row[13]);
|
||||
const sinBeta = Math.sin(row[14]);
|
||||
@@ -156,26 +197,46 @@ export class ArcRotateCamera extends Camera {
|
||||
row[18] -= horizontal * sinAlpha + vertical * cosBeta * cosAlpha;
|
||||
});
|
||||
} else {
|
||||
const speed = this.#controls.orbitSpeed ?? 0.005;
|
||||
const speed = this.#controls?.orbitSpeed ?? 0.005;
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => {
|
||||
row[13] -= x * speed;
|
||||
row[14] = Math.min(
|
||||
Math.PI - 0.001,
|
||||
Math.max(0.001, row[14] + y * speed),
|
||||
Math.max(0.001, row[14] - y * speed),
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
#wheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => {
|
||||
row[15] = Math.max(
|
||||
0.01,
|
||||
row[15] * Math.exp(event.deltaY * (this.#controls?.zoomSpeed ?? 0.001)),
|
||||
);
|
||||
this.#motion[4] += event.deltaY * (this.#controls?.zoomSpeed ?? 0.001);
|
||||
this.#scheduleInertia();
|
||||
};
|
||||
|
||||
#scheduleInertia() {
|
||||
if (this.#inertiaFrame) return;
|
||||
this.#lastFrame = performance.now();
|
||||
this.#inertiaFrame = requestAnimationFrame(this.#tickInertia);
|
||||
}
|
||||
|
||||
#tickInertia = (now: number) => {
|
||||
this.#inertiaFrame = 0;
|
||||
const fraction = -Math.expm1(-Math.max(0, now - this.#lastFrame) / 80);
|
||||
this.#lastFrame = now;
|
||||
const step = this.#motion.map((remaining, index) => {
|
||||
const value =
|
||||
Math.abs(remaining) < 0.00001 ? remaining : remaining * fraction;
|
||||
this.#motion[index] -= value;
|
||||
return value;
|
||||
});
|
||||
this.batchTransformChanges(() => {
|
||||
if (step[0] || step[1]) this.#applyDrag(step[0], step[1], false);
|
||||
if (step[2] || step[3]) this.#applyDrag(step[2], step[3], true);
|
||||
if (step[4]) this.radius *= Math.exp(step[4]);
|
||||
});
|
||||
if (this.#motion.some(Boolean))
|
||||
this.#inertiaFrame = requestAnimationFrame(this.#tickInertia);
|
||||
};
|
||||
#pollController = () => {
|
||||
const pad = navigator.getGamepads?.().find(Boolean);
|
||||
@@ -185,17 +246,12 @@ export class ArcRotateCamera extends Camera {
|
||||
row[13] += (pad.axes[2] ?? pad.axes[0] ?? 0) * 0.03;
|
||||
row[14] = Math.min(
|
||||
Math.PI - 0.001,
|
||||
Math.max(
|
||||
0.001,
|
||||
row[14] + (pad.axes[3] ?? pad.axes[1] ?? 0) * 0.03,
|
||||
),
|
||||
Math.max(0.001, row[14] + (pad.axes[3] ?? pad.axes[1] ?? 0) * 0.03),
|
||||
);
|
||||
row[15] = Math.max(
|
||||
0.01,
|
||||
row[15] +
|
||||
((pad.buttons[6]?.value ?? 0) -
|
||||
(pad.buttons[7]?.value ?? 0)) *
|
||||
0.1,
|
||||
((pad.buttons[6]?.value ?? 0) - (pad.buttons[7]?.value ?? 0)) * 0.1,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Mesh } from "../Mesh";
|
||||
import type { Scene } from "../Scene";
|
||||
import { DirectionalLight } from "../lights/DirectionalLight";
|
||||
import { PointLight } from "../lights/PointLight";
|
||||
import { SpotLight } from "../lights/SpotLight";
|
||||
import { PBRMaterial } from "../materials/PBRMaterial";
|
||||
import { Texture } from "../materials/Texture";
|
||||
|
||||
@@ -42,6 +45,20 @@ export async function importGltf(scene: Scene, url: string | URL) {
|
||||
await scene.reserve({
|
||||
nodes: result.primitives.length,
|
||||
materials: result.materials.length,
|
||||
vertices: result.primitives.reduce(
|
||||
(total: number, primitive: any) => total + primitive.positions.length / 3,
|
||||
0,
|
||||
),
|
||||
// Each geometry's index span is padded to whole rows of four.
|
||||
indices: result.primitives.reduce(
|
||||
(total: number, primitive: any) =>
|
||||
total +
|
||||
Math.ceil(
|
||||
(primitive.indices?.length ?? primitive.positions.length / 3) / 4,
|
||||
) *
|
||||
4,
|
||||
0,
|
||||
),
|
||||
});
|
||||
let textures: Texture[] = [];
|
||||
const imported = await scene.batchGraphUpdates(async () => {
|
||||
@@ -49,12 +66,20 @@ export async function importGltf(scene: Scene, url: string | URL) {
|
||||
for (const material of result.materials) {
|
||||
if (material.baseColorTexture >= 0) srgb.add(material.baseColorTexture);
|
||||
if (material.emissiveTexture >= 0) srgb.add(material.emissiveTexture);
|
||||
if (material.flags & 16 && material.metallicRoughnessTexture >= 0)
|
||||
srgb.add(material.metallicRoughnessTexture);
|
||||
if (material.specularColorTexture >= 0)
|
||||
srgb.add(material.specularColorTexture);
|
||||
}
|
||||
textures = result.textures.map(
|
||||
({ image }: { image: ImageBitmap }, index: number) =>
|
||||
(
|
||||
{ image, sampler }: { image: ImageBitmap; sampler: any },
|
||||
index: number,
|
||||
) =>
|
||||
new Texture(scene, {
|
||||
source: image,
|
||||
format: srgb.has(index) ? "rgba8unorm-srgb" : "rgba8unorm",
|
||||
sampler,
|
||||
}),
|
||||
);
|
||||
await Promise.all(textures.map((texture: Texture) => texture.ready));
|
||||
@@ -63,10 +88,19 @@ export async function importGltf(scene: Scene, url: string | URL) {
|
||||
new PBRMaterial(scene, {
|
||||
...options,
|
||||
baseColorTexture: textures[options.baseColorTexture],
|
||||
metallicRoughnessTexture:
|
||||
textures[options.metallicRoughnessTexture],
|
||||
metallicRoughnessTexture: textures[options.metallicRoughnessTexture],
|
||||
normalTexture: textures[options.normalTexture],
|
||||
emissiveTexture: textures[options.emissiveTexture],
|
||||
occlusionTexture: textures[options.occlusionTexture],
|
||||
transmissionTexture: textures[options.transmissionTexture],
|
||||
thicknessTexture: textures[options.thicknessTexture],
|
||||
clearcoatTexture: textures[options.clearcoatTexture],
|
||||
iridescenceTexture: textures[options.iridescenceTexture],
|
||||
iridescenceThicknessTexture:
|
||||
textures[options.iridescenceThicknessTexture],
|
||||
anisotropyTexture: textures[options.anisotropyTexture],
|
||||
specularTexture: textures[options.specularTexture],
|
||||
specularColorTexture: textures[options.specularColorTexture],
|
||||
}),
|
||||
);
|
||||
await Promise.all(materials.map((material: PBRMaterial) => material.ready));
|
||||
@@ -83,12 +117,20 @@ export async function importGltf(scene: Scene, url: string | URL) {
|
||||
...(primitive.normals ? { normals: primitive.normals } : {}),
|
||||
...(primitive.tangents ? { tangents: primitive.tangents } : {}),
|
||||
...(primitive.uvs ? { uvs: primitive.uvs } : {}),
|
||||
...(primitive.uvs2 ? { uvs2: primitive.uvs2 } : {}),
|
||||
...(primitive.colors ? { colors: primitive.colors } : {}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
await Promise.all(meshes.map((mesh) => mesh.ready));
|
||||
return meshes;
|
||||
const lights = (result.lights ?? []).map((options: any) => {
|
||||
if (options.type === "directional")
|
||||
return new DirectionalLight(scene, options);
|
||||
if (options.type === "spot") return new SpotLight(scene, options);
|
||||
return new PointLight(scene, options);
|
||||
});
|
||||
await Promise.all(lights.map((light: any) => light.ready));
|
||||
return Object.assign(meshes, { camera: result.camera });
|
||||
});
|
||||
await Promise.all(
|
||||
result.textures.map((_: unknown, index: number) =>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
export {};
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const widths: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
|
||||
const widths: Record<string, number> = {
|
||||
SCALAR: 1,
|
||||
VEC2: 2,
|
||||
VEC3: 3,
|
||||
VEC4: 4,
|
||||
MAT2: 4,
|
||||
MAT3: 9,
|
||||
MAT4: 16,
|
||||
};
|
||||
const sizes: Record<number, number> = {
|
||||
5120: 1,
|
||||
5121: 1,
|
||||
@@ -29,79 +37,165 @@ function component(view: DataView, offset: number, type: number) {
|
||||
throw new Error("GLTF_COMPONENT");
|
||||
}
|
||||
|
||||
function rotate(rotor: number[], value: number[]) {
|
||||
const [rx, ry, rz, rw] = rotor;
|
||||
const [x, y, z] = value;
|
||||
const tx = 2 * (ry * z - rz * y);
|
||||
const ty = 2 * (rz * x - rx * z);
|
||||
const tz = 2 * (rx * y - ry * x);
|
||||
return [
|
||||
x + rw * tx + ry * tz - rz * ty,
|
||||
y + rw * ty + rz * tx - rx * tz,
|
||||
z + rw * tz + rx * ty - ry * tx,
|
||||
];
|
||||
}
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
|
||||
function multiply(a: number[], b: number[]) {
|
||||
return [
|
||||
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
||||
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
||||
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
||||
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
||||
];
|
||||
function multiply(a: ArrayLike<number>, b: ArrayLike<number>) {
|
||||
const result = new Array<number>(16);
|
||||
for (let column = 0; column < 4; column++)
|
||||
for (let row = 0; row < 4; row++) {
|
||||
let value = 0;
|
||||
for (let lane = 0; lane < 4; lane++)
|
||||
value += a[lane * 4 + row] * b[column * 4 + lane];
|
||||
result[column * 4 + row] = value;
|
||||
}
|
||||
|
||||
function decompose(matrix: number[]) {
|
||||
const scale = [
|
||||
Math.hypot(matrix[0], matrix[1], matrix[2]),
|
||||
Math.hypot(matrix[4], matrix[5], matrix[6]),
|
||||
Math.hypot(matrix[8], matrix[9], matrix[10]),
|
||||
];
|
||||
const [sx, sy, sz] = scale;
|
||||
const [m00, m01, m02] = [matrix[0] / sx, matrix[4] / sy, matrix[8] / sz];
|
||||
const [m10, m11, m12] = [matrix[1] / sx, matrix[5] / sy, matrix[9] / sz];
|
||||
const [m20, m21, m22] = [matrix[2] / sx, matrix[6] / sy, matrix[10] / sz];
|
||||
let rotor: number[];
|
||||
if (m00 + m11 + m22 > 0) {
|
||||
const s = Math.sqrt(1 + m00 + m11 + m22) * 2;
|
||||
rotor = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, s / 4];
|
||||
} else if (m00 > m11 && m00 > m22) {
|
||||
const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
|
||||
rotor = [s / 4, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s];
|
||||
} else if (m11 > m22) {
|
||||
const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
|
||||
rotor = [(m01 + m10) / s, s / 4, (m12 + m21) / s, (m02 - m20) / s];
|
||||
} else {
|
||||
const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
|
||||
rotor = [(m02 + m20) / s, (m12 + m21) / s, s / 4, (m10 - m01) / s];
|
||||
}
|
||||
return { position: matrix.slice(12, 15), rotor, scale };
|
||||
return result;
|
||||
}
|
||||
|
||||
function transform(node: any) {
|
||||
return node.matrix
|
||||
? decompose(node.matrix)
|
||||
: {
|
||||
position: node.translation ?? [0, 0, 0],
|
||||
rotor: node.rotation ?? [0, 0, 0, 1],
|
||||
scale: node.scale ?? [1, 1, 1],
|
||||
};
|
||||
if (node.matrix) 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 xx = x * x;
|
||||
const yy = y * y;
|
||||
const zz = z * z;
|
||||
const xy = x * y;
|
||||
const xz = x * z;
|
||||
const yz = y * z;
|
||||
const wx = w * x;
|
||||
const wy = w * y;
|
||||
const wz = w * z;
|
||||
return [
|
||||
(1 - 2 * (yy + zz)) * sx,
|
||||
2 * (xy + wz) * sx,
|
||||
2 * (xz - wy) * sx,
|
||||
0,
|
||||
2 * (xy - wz) * sy,
|
||||
(1 - 2 * (xx + zz)) * sy,
|
||||
2 * (yz + wx) * sy,
|
||||
0,
|
||||
2 * (xz + wy) * sz,
|
||||
2 * (yz - wx) * sz,
|
||||
(1 - 2 * (xx + yy)) * sz,
|
||||
0,
|
||||
tx,
|
||||
ty,
|
||||
tz,
|
||||
1,
|
||||
];
|
||||
}
|
||||
|
||||
function compose(parent: any, local: any) {
|
||||
const position = rotate(
|
||||
parent.rotor,
|
||||
local.position.map(
|
||||
(value: number, lane: number) => value * parent.scale[lane],
|
||||
),
|
||||
function determinant(matrix: ArrayLike<number>) {
|
||||
return (
|
||||
matrix[0] * (matrix[5] * matrix[10] - matrix[6] * matrix[9]) -
|
||||
matrix[4] * (matrix[1] * matrix[10] - matrix[2] * matrix[9]) +
|
||||
matrix[8] * (matrix[1] * matrix[6] - matrix[2] * matrix[5])
|
||||
);
|
||||
return {
|
||||
position: position.map((value, lane) => value + parent.position[lane]),
|
||||
rotor: multiply(parent.rotor, local.rotor),
|
||||
scale: local.scale.map(
|
||||
(value: number, lane: number) => value * parent.scale[lane],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function point(matrix: ArrayLike<number>, source: ArrayLike<number>) {
|
||||
return [
|
||||
matrix[0] * source[0] +
|
||||
matrix[4] * source[1] +
|
||||
matrix[8] * source[2] +
|
||||
matrix[12],
|
||||
matrix[1] * source[0] +
|
||||
matrix[5] * source[1] +
|
||||
matrix[9] * source[2] +
|
||||
matrix[13],
|
||||
matrix[2] * source[0] +
|
||||
matrix[6] * source[1] +
|
||||
matrix[10] * source[2] +
|
||||
matrix[14],
|
||||
];
|
||||
}
|
||||
|
||||
function vector(matrix: ArrayLike<number>, source: ArrayLike<number>) {
|
||||
const x =
|
||||
matrix[0] * source[0] + matrix[4] * source[1] + matrix[8] * source[2];
|
||||
const y =
|
||||
matrix[1] * source[0] + matrix[5] * source[1] + matrix[9] * source[2];
|
||||
const z =
|
||||
matrix[2] * source[0] + matrix[6] * source[1] + matrix[10] * source[2];
|
||||
const length = Math.hypot(x, y, z) || 1;
|
||||
return [x / length, y / length, z / length];
|
||||
}
|
||||
|
||||
function direction(matrix: ArrayLike<number>, source: ArrayLike<number>) {
|
||||
const a = matrix[0];
|
||||
const b = matrix[4];
|
||||
const c = matrix[8];
|
||||
const d = matrix[1];
|
||||
const e = matrix[5];
|
||||
const f = matrix[9];
|
||||
const g = matrix[2];
|
||||
const h = matrix[6];
|
||||
const i = matrix[10];
|
||||
const inverse = 1 / determinant(matrix);
|
||||
const x =
|
||||
((e * i - f * h) * source[0] +
|
||||
(f * g - d * i) * source[1] +
|
||||
(d * h - e * g) * source[2]) *
|
||||
inverse;
|
||||
const y =
|
||||
((c * h - b * i) * source[0] +
|
||||
(a * i - c * g) * source[1] +
|
||||
(b * g - a * h) * source[2]) *
|
||||
inverse;
|
||||
const z =
|
||||
((b * f - c * e) * source[0] +
|
||||
(c * d - a * f) * source[1] +
|
||||
(a * e - b * d) * source[2]) *
|
||||
inverse;
|
||||
const length = Math.hypot(x, y, z) || 1;
|
||||
return [x / length, y / length, z / length];
|
||||
}
|
||||
|
||||
function rotor(matrix: ArrayLike<number>) {
|
||||
const sx = Math.hypot(matrix[0], matrix[1], matrix[2]) || 1;
|
||||
const sy = Math.hypot(matrix[4], matrix[5], matrix[6]) || 1;
|
||||
const sz = Math.hypot(matrix[8], matrix[9], matrix[10]) || 1;
|
||||
const m00 = matrix[0] / sx;
|
||||
const m01 = matrix[4] / sy;
|
||||
const m02 = matrix[8] / sz;
|
||||
const m10 = matrix[1] / sx;
|
||||
const m11 = matrix[5] / sy;
|
||||
const m12 = matrix[9] / sz;
|
||||
const m20 = matrix[2] / sx;
|
||||
const m21 = matrix[6] / sy;
|
||||
const m22 = matrix[10] / sz;
|
||||
const trace = m00 + m11 + m22;
|
||||
let x: number;
|
||||
let y: number;
|
||||
let z: number;
|
||||
let w: number;
|
||||
if (trace > 0) {
|
||||
const scale = Math.sqrt(trace + 1) * 2;
|
||||
x = (m21 - m12) / scale;
|
||||
y = (m02 - m20) / scale;
|
||||
z = (m10 - m01) / scale;
|
||||
w = scale / 4;
|
||||
} else if (m00 > m11 && m00 > m22) {
|
||||
const scale = Math.sqrt(1 + m00 - m11 - m22) * 2;
|
||||
x = scale / 4;
|
||||
y = (m01 + m10) / scale;
|
||||
z = (m02 + m20) / scale;
|
||||
w = (m21 - m12) / scale;
|
||||
} else if (m11 > m22) {
|
||||
const scale = Math.sqrt(1 + m11 - m00 - m22) * 2;
|
||||
x = (m01 + m10) / scale;
|
||||
y = scale / 4;
|
||||
z = (m12 + m21) / scale;
|
||||
w = (m02 - m20) / scale;
|
||||
} else {
|
||||
const scale = Math.sqrt(1 + m22 - m00 - m11) * 2;
|
||||
x = (m02 + m20) / scale;
|
||||
y = (m12 + m21) / scale;
|
||||
z = scale / 4;
|
||||
w = (m10 - m01) / scale;
|
||||
}
|
||||
const length = Math.hypot(x, y, z, w) || 1;
|
||||
return [x / length, y / length, z / length, w / length];
|
||||
}
|
||||
|
||||
function parse(bytes: Uint8Array) {
|
||||
@@ -127,7 +221,127 @@ function parse(bytes: Uint8Array) {
|
||||
return { document, binary };
|
||||
}
|
||||
|
||||
async function load(url: string) {
|
||||
function computeNormals(positions: Float32Array, indices: Uint32Array) {
|
||||
const normals = new Float32Array(positions.length);
|
||||
for (let face = 0; face + 2 < indices.length; face += 3) {
|
||||
const a = indices[face] * 3;
|
||||
const b = indices[face + 1] * 3;
|
||||
const c = indices[face + 2] * 3;
|
||||
const ab = [
|
||||
positions[b] - positions[a],
|
||||
positions[b + 1] - positions[a + 1],
|
||||
positions[b + 2] - positions[a + 2],
|
||||
];
|
||||
const ac = [
|
||||
positions[c] - positions[a],
|
||||
positions[c + 1] - positions[a + 1],
|
||||
positions[c + 2] - positions[a + 2],
|
||||
];
|
||||
const cross = [
|
||||
ab[1] * ac[2] - ab[2] * ac[1],
|
||||
ab[2] * ac[0] - ab[0] * ac[2],
|
||||
ab[0] * ac[1] - ab[1] * ac[0],
|
||||
];
|
||||
for (const vertex of [a, b, c])
|
||||
for (let lane = 0; lane < 3; lane++)
|
||||
normals[vertex + lane] += cross[lane];
|
||||
}
|
||||
for (let vertex = 0; vertex < normals.length; vertex += 3) {
|
||||
const length =
|
||||
Math.hypot(normals[vertex], normals[vertex + 1], normals[vertex + 2]) ||
|
||||
1;
|
||||
for (let lane = 0; lane < 3; lane++) normals[vertex + lane] /= length;
|
||||
}
|
||||
return normals;
|
||||
}
|
||||
|
||||
function computeTangents(
|
||||
positions: Float32Array,
|
||||
normals: Float32Array,
|
||||
uvs: Float32Array,
|
||||
indices: Uint32Array,
|
||||
) {
|
||||
const first = new Float32Array(positions.length);
|
||||
const second = new Float32Array(positions.length);
|
||||
for (let face = 0; face + 2 < indices.length; face += 3) {
|
||||
const ia = indices[face];
|
||||
const ib = indices[face + 1];
|
||||
const ic = indices[face + 2];
|
||||
const a = ia * 3;
|
||||
const b = ib * 3;
|
||||
const c = ic * 3;
|
||||
const au = ia * 2;
|
||||
const bu = ib * 2;
|
||||
const cu = ic * 2;
|
||||
const x1 = positions[b] - positions[a];
|
||||
const x2 = positions[c] - positions[a];
|
||||
const y1 = positions[b + 1] - positions[a + 1];
|
||||
const y2 = positions[c + 1] - positions[a + 1];
|
||||
const z1 = positions[b + 2] - positions[a + 2];
|
||||
const z2 = positions[c + 2] - positions[a + 2];
|
||||
const s1 = uvs[bu] - uvs[au];
|
||||
const s2 = uvs[cu] - uvs[au];
|
||||
const t1 = uvs[bu + 1] - uvs[au + 1];
|
||||
const t2 = uvs[cu + 1] - uvs[au + 1];
|
||||
const divisor = s1 * t2 - s2 * t1;
|
||||
if (Math.abs(divisor) < 1e-10) continue;
|
||||
const r = 1 / divisor;
|
||||
const tangent = [
|
||||
(t2 * x1 - t1 * x2) * r,
|
||||
(t2 * y1 - t1 * y2) * r,
|
||||
(t2 * z1 - t1 * z2) * r,
|
||||
];
|
||||
const bitangent = [
|
||||
(s1 * x2 - s2 * x1) * r,
|
||||
(s1 * y2 - s2 * y1) * r,
|
||||
(s1 * z2 - s2 * z1) * r,
|
||||
];
|
||||
for (const vertex of [a, b, c])
|
||||
for (let lane = 0; lane < 3; lane++) {
|
||||
first[vertex + lane] += tangent[lane];
|
||||
second[vertex + lane] += bitangent[lane];
|
||||
}
|
||||
}
|
||||
const tangents = new Float32Array((positions.length / 3) * 4);
|
||||
for (let vertex = 0; vertex < positions.length / 3; vertex++) {
|
||||
const at = vertex * 3;
|
||||
const output = vertex * 4;
|
||||
const normal = normals.subarray(at, at + 3);
|
||||
const source = first.subarray(at, at + 3);
|
||||
const projection =
|
||||
normal[0] * source[0] + normal[1] * source[1] + normal[2] * source[2];
|
||||
let x = source[0] - normal[0] * projection;
|
||||
let y = source[1] - normal[1] * projection;
|
||||
let z = source[2] - normal[2] * projection;
|
||||
const length = Math.hypot(x, y, z) || 1;
|
||||
x /= length;
|
||||
y /= length;
|
||||
z /= length;
|
||||
tangents[output] = x;
|
||||
tangents[output + 1] = y;
|
||||
tangents[output + 2] = z;
|
||||
tangents[output + 3] =
|
||||
normal[0] * (y * second[at + 2] - z * second[at + 1]) +
|
||||
normal[1] * (z * second[at] - x * second[at + 2]) +
|
||||
normal[2] * (x * second[at + 1] - y * second[at]) <
|
||||
0
|
||||
? -1
|
||||
: 1;
|
||||
}
|
||||
return tangents;
|
||||
}
|
||||
|
||||
function color4(values: Float32Array, width: number) {
|
||||
if (width === 4) return values;
|
||||
const result = new Float32Array((values.length / width) * 4);
|
||||
for (let vertex = 0; vertex < values.length / width; vertex++) {
|
||||
result.set(values.subarray(vertex * width, vertex * width + 3), vertex * 4);
|
||||
result[vertex * 4 + 3] = 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function loadGltf(url: string) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
||||
const { document, binary } = parse(
|
||||
@@ -159,37 +373,75 @@ async function load(url: string) {
|
||||
return result.blob();
|
||||
}),
|
||||
);
|
||||
const samplerDescriptor = (id: number | undefined) => {
|
||||
const sampler = id === undefined ? {} : (document.samplers?.[id] ?? {});
|
||||
const addressMode = (mode: number | undefined) =>
|
||||
mode === 33071
|
||||
? "clamp-to-edge"
|
||||
: mode === 33648
|
||||
? "mirror-repeat"
|
||||
: "repeat";
|
||||
return {
|
||||
magFilter: sampler.magFilter === 9728 ? "nearest" : "linear",
|
||||
minFilter:
|
||||
sampler.minFilter === 9728 ||
|
||||
sampler.minFilter === 9984 ||
|
||||
sampler.minFilter === 9986
|
||||
? "nearest"
|
||||
: "linear",
|
||||
mipmapFilter:
|
||||
sampler.minFilter === 9984 || sampler.minFilter === 9985
|
||||
? "nearest"
|
||||
: "linear",
|
||||
addressModeU: addressMode(sampler.wrapS),
|
||||
addressModeV: addressMode(sampler.wrapT),
|
||||
};
|
||||
};
|
||||
const textures = await Promise.all(
|
||||
(document.textures ?? []).map(async (texture: any) => ({
|
||||
image: await createImageBitmap(imageBlobs[texture.source]),
|
||||
sampler: samplerDescriptor(texture.sampler),
|
||||
})),
|
||||
);
|
||||
|
||||
const normalized = (value: number, type: number) => {
|
||||
if (type === 5120) return Math.max(value / 127, -1);
|
||||
if (type === 5121) return value / 255;
|
||||
if (type === 5122) return Math.max(value / 32_767, -1);
|
||||
if (type === 5123) return value / 65_535;
|
||||
return value;
|
||||
};
|
||||
const accessor = (id: number, integer = false) => {
|
||||
const source = document.accessors[id];
|
||||
const width = widths[source.type];
|
||||
const size = sizes[source.componentType];
|
||||
const values = integer
|
||||
? new Uint32Array(source.count * width)
|
||||
: new Float32Array(source.count * width);
|
||||
if (source.bufferView !== undefined) {
|
||||
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);
|
||||
const view = new DataView(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
bytes.byteLength,
|
||||
);
|
||||
if (
|
||||
!source.sparse &&
|
||||
!source.normalized &&
|
||||
stride === width * size &&
|
||||
(bytes.byteOffset + start) % size === 0
|
||||
) {
|
||||
const packed = new arrays[source.componentType](
|
||||
values.set(
|
||||
new arrays[source.componentType](
|
||||
bytes.buffer,
|
||||
bytes.byteOffset + start,
|
||||
source.count * width,
|
||||
),
|
||||
);
|
||||
values.set(packed);
|
||||
return values;
|
||||
}
|
||||
} else
|
||||
for (let item = 0; item < source.count; item++)
|
||||
for (let lane = 0; lane < width; lane++) {
|
||||
let value = component(
|
||||
@@ -197,105 +449,437 @@ async function load(url: string) {
|
||||
start + item * stride + lane * size,
|
||||
source.componentType,
|
||||
);
|
||||
if (!integer && source.normalized) {
|
||||
const maximum =
|
||||
source.componentType === 5121
|
||||
? 255
|
||||
: source.componentType === 5123
|
||||
? 65535
|
||||
: 1;
|
||||
value /= maximum;
|
||||
}
|
||||
if (!integer && source.normalized)
|
||||
value = normalized(value, source.componentType);
|
||||
values[item * width + lane] = value;
|
||||
}
|
||||
}
|
||||
if (source.sparse) {
|
||||
const sparse = source.sparse;
|
||||
const indexView = document.bufferViews[sparse.indices.bufferView];
|
||||
const indexBytes = buffers[indexView.buffer];
|
||||
const indexStart =
|
||||
(indexView.byteOffset ?? 0) + (sparse.indices.byteOffset ?? 0);
|
||||
const indexData = new DataView(
|
||||
indexBytes.buffer,
|
||||
indexBytes.byteOffset,
|
||||
indexBytes.byteLength,
|
||||
);
|
||||
const indexSize = sizes[sparse.indices.componentType];
|
||||
const valueView = document.bufferViews[sparse.values.bufferView];
|
||||
const valueBytes = buffers[valueView.buffer];
|
||||
const valueStart =
|
||||
(valueView.byteOffset ?? 0) + (sparse.values.byteOffset ?? 0);
|
||||
const valueData = new DataView(
|
||||
valueBytes.buffer,
|
||||
valueBytes.byteOffset,
|
||||
valueBytes.byteLength,
|
||||
);
|
||||
for (let item = 0; item < sparse.count; item++) {
|
||||
const target = component(
|
||||
indexData,
|
||||
indexStart + item * indexSize,
|
||||
sparse.indices.componentType,
|
||||
);
|
||||
for (let lane = 0; lane < width; lane++) {
|
||||
let value = component(
|
||||
valueData,
|
||||
valueStart + (item * width + lane) * size,
|
||||
source.componentType,
|
||||
);
|
||||
if (!integer && source.normalized)
|
||||
value = normalized(value, source.componentType);
|
||||
values[target * width + lane] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
const materials = (document.materials ?? []).map((material: any) => {
|
||||
const pbr = material.pbrMetallicRoughness ?? {};
|
||||
const textureInfo = (info: any) => {
|
||||
if (!info) return undefined;
|
||||
const extension = info.extensions?.KHR_texture_transform ?? {};
|
||||
return {
|
||||
baseColor: pbr.baseColorFactor ?? [1, 1, 1, 1],
|
||||
metallic: pbr.metallicFactor ?? 0,
|
||||
roughness: pbr.roughnessFactor ?? 0.7,
|
||||
texture: info.index,
|
||||
texCoord: extension.texCoord ?? info.texCoord ?? 0,
|
||||
offset: extension.offset ?? [0, 0],
|
||||
scale: extension.scale ?? [1, 1],
|
||||
rotation: extension.rotation ?? 0,
|
||||
};
|
||||
};
|
||||
const materials = (document.materials ?? []).map((material: any) => {
|
||||
const extensions = material.extensions ?? {};
|
||||
const specGloss = extensions.KHR_materials_pbrSpecularGlossiness;
|
||||
const pbr = material.pbrMetallicRoughness ?? {};
|
||||
const transmission = extensions.KHR_materials_transmission ?? {};
|
||||
const volume = extensions.KHR_materials_volume ?? {};
|
||||
const clearcoat = extensions.KHR_materials_clearcoat ?? {};
|
||||
const sheen = extensions.KHR_materials_sheen ?? {};
|
||||
const specular = extensions.KHR_materials_specular ?? {};
|
||||
const iridescence = extensions.KHR_materials_iridescence ?? {};
|
||||
const anisotropy = extensions.KHR_materials_anisotropy ?? {};
|
||||
const alphaMode =
|
||||
material.alphaMode === "MASK"
|
||||
? 1
|
||||
: material.alphaMode === "BLEND"
|
||||
? 2
|
||||
: 0;
|
||||
const flags =
|
||||
alphaMode |
|
||||
(material.doubleSided ? 4 : 0) |
|
||||
(extensions.KHR_materials_unlit ? 8 : 0) |
|
||||
(specGloss ? 16 : 0);
|
||||
const infos = [
|
||||
textureInfo(specGloss?.diffuseTexture ?? pbr.baseColorTexture),
|
||||
textureInfo(
|
||||
specGloss?.specularGlossinessTexture ?? pbr.metallicRoughnessTexture,
|
||||
),
|
||||
textureInfo(material.normalTexture),
|
||||
textureInfo(material.emissiveTexture),
|
||||
textureInfo(material.occlusionTexture),
|
||||
textureInfo(transmission.transmissionTexture),
|
||||
textureInfo(volume.thicknessTexture),
|
||||
textureInfo(clearcoat.clearcoatTexture),
|
||||
textureInfo(iridescence.iridescenceTexture),
|
||||
textureInfo(iridescence.iridescenceThicknessTexture),
|
||||
textureInfo(anisotropy.anisotropyTexture),
|
||||
textureInfo(specular.specularTexture),
|
||||
textureInfo(specular.specularColorTexture),
|
||||
];
|
||||
return {
|
||||
baseColor: specGloss?.diffuseFactor ??
|
||||
pbr.baseColorFactor ?? [1, 1, 1, 1],
|
||||
metallic: specGloss ? 0 : (pbr.metallicFactor ?? 1),
|
||||
roughness: specGloss
|
||||
? 1 - (specGloss.glossinessFactor ?? 1)
|
||||
: (pbr.roughnessFactor ?? 1),
|
||||
emissive: material.emissiveFactor ?? [0, 0, 0],
|
||||
normalScale: material.normalTexture?.scale ?? 1,
|
||||
alphaCutoff: material.alphaCutoff ?? 0.5,
|
||||
baseColorTexture: pbr.baseColorTexture?.index ?? -1,
|
||||
metallicRoughnessTexture: pbr.metallicRoughnessTexture?.index ?? -1,
|
||||
normalTexture: material.normalTexture?.index ?? -1,
|
||||
emissiveTexture: material.emissiveTexture?.index ?? -1,
|
||||
flags,
|
||||
occlusionStrength: material.occlusionTexture?.strength ?? 1,
|
||||
transmission: transmission.transmissionFactor ?? 0,
|
||||
ior: extensions.KHR_materials_ior?.ior ?? 1.5,
|
||||
thickness: volume.thicknessFactor ?? 0,
|
||||
attenuationDistance: volume.attenuationDistance ?? 0,
|
||||
attenuationColor: volume.attenuationColor ?? [1, 1, 1],
|
||||
emissiveStrength:
|
||||
extensions.KHR_materials_emissive_strength?.emissiveStrength ?? 1,
|
||||
clearcoat: clearcoat.clearcoatFactor ?? 0,
|
||||
clearcoatRoughness: clearcoat.clearcoatRoughnessFactor ?? 0,
|
||||
specular: specular.specularFactor ?? 1,
|
||||
sheenColor: sheen.sheenColorFactor ?? [0, 0, 0],
|
||||
sheenRoughness: sheen.sheenRoughnessFactor ?? 0,
|
||||
specularColor: specGloss?.specularFactor ?? [1, 1, 1],
|
||||
iridescence: iridescence.iridescenceFactor ?? 0,
|
||||
iridescenceIor: iridescence.iridescenceIor ?? 1.3,
|
||||
iridescenceThicknessMinimum:
|
||||
iridescence.iridescenceThicknessMinimum ?? 100,
|
||||
iridescenceThicknessMaximum:
|
||||
iridescence.iridescenceThicknessMaximum ?? 400,
|
||||
anisotropy: anisotropy.anisotropyStrength ?? 0,
|
||||
anisotropyRotation: anisotropy.anisotropyRotation ?? 0,
|
||||
specularExtensionColor: specular.specularColorFactor ?? [1, 1, 1],
|
||||
textureInfos: infos,
|
||||
baseColorTexture: infos[0]?.texture ?? -1,
|
||||
metallicRoughnessTexture: infos[1]?.texture ?? -1,
|
||||
normalTexture: infos[2]?.texture ?? -1,
|
||||
emissiveTexture: infos[3]?.texture ?? -1,
|
||||
occlusionTexture: infos[4]?.texture ?? -1,
|
||||
transmissionTexture: infos[5]?.texture ?? -1,
|
||||
thicknessTexture: infos[6]?.texture ?? -1,
|
||||
clearcoatTexture: infos[7]?.texture ?? -1,
|
||||
iridescenceTexture: infos[8]?.texture ?? -1,
|
||||
iridescenceThicknessTexture: infos[9]?.texture ?? -1,
|
||||
anisotropyTexture: infos[10]?.texture ?? -1,
|
||||
specularTexture: infos[11]?.texture ?? -1,
|
||||
specularColorTexture: infos[12]?.texture ?? -1,
|
||||
};
|
||||
});
|
||||
|
||||
const nodes = document.nodes ?? [];
|
||||
const parents = new Map<number, number>();
|
||||
for (let id = 0; id < nodes.length; id++)
|
||||
for (const child of nodes[id].children ?? []) parents.set(child, id);
|
||||
const worlds = new Map<number, number[]>();
|
||||
const world = (id: number): number[] => {
|
||||
const existing = worlds.get(id);
|
||||
if (existing) return existing;
|
||||
const local = transform(nodes[id] ?? {});
|
||||
const parent = parents.get(id);
|
||||
const result =
|
||||
parent === undefined ? local : multiply(world(parent), local);
|
||||
worlds.set(id, result);
|
||||
return result;
|
||||
};
|
||||
for (let id = 0; id < nodes.length; id++) world(id);
|
||||
|
||||
const primitives: any[] = [];
|
||||
const emitMesh = (meshId: number, transform: any) => {
|
||||
const emitMesh = (meshId: number, nodeId: number, matrix: number[]) => {
|
||||
const mesh = document.meshes?.[meshId];
|
||||
const node = nodes[nodeId] ?? {};
|
||||
const skin =
|
||||
node.skin === undefined ? undefined : document.skins?.[node.skin];
|
||||
const inverseBindMatrices =
|
||||
skin?.inverseBindMatrices === undefined
|
||||
? undefined
|
||||
: (accessor(skin.inverseBindMatrices) as Float32Array);
|
||||
const jointMatrices = skin?.joints.map((joint: number, index: number) =>
|
||||
multiply(
|
||||
world(joint),
|
||||
inverseBindMatrices?.subarray(index * 16, index * 16 + 16) ?? identity,
|
||||
),
|
||||
);
|
||||
for (const primitive of mesh?.primitives ?? []) {
|
||||
if (
|
||||
(primitive.mode ?? 4) !== 4 ||
|
||||
primitive.attributes.POSITION === undefined
|
||||
)
|
||||
continue;
|
||||
const positions = accessor(primitive.attributes.POSITION);
|
||||
const positions = accessor(primitive.attributes.POSITION) as Float32Array;
|
||||
let normals =
|
||||
primitive.attributes.NORMAL === undefined
|
||||
? undefined
|
||||
: (accessor(primitive.attributes.NORMAL) as Float32Array);
|
||||
let tangents =
|
||||
primitive.attributes.TANGENT === undefined
|
||||
? undefined
|
||||
: (accessor(primitive.attributes.TANGENT) as Float32Array);
|
||||
const uvs =
|
||||
primitive.attributes.TEXCOORD_0 === undefined
|
||||
? new Float32Array((positions.length / 3) * 2)
|
||||
: (accessor(primitive.attributes.TEXCOORD_0) as Float32Array);
|
||||
const uvs2 =
|
||||
primitive.attributes.TEXCOORD_1 === undefined
|
||||
? undefined
|
||||
: (accessor(primitive.attributes.TEXCOORD_1) as Float32Array);
|
||||
const indices =
|
||||
primitive.indices === undefined
|
||||
? Uint32Array.from(
|
||||
{ length: positions.length / 3 },
|
||||
(_, index) => index,
|
||||
)
|
||||
: accessor(primitive.indices, true);
|
||||
: (accessor(primitive.indices, true) as Uint32Array);
|
||||
const weights = node.weights ?? mesh.weights ?? [];
|
||||
for (
|
||||
let target = 0;
|
||||
target < (primitive.targets?.length ?? 0);
|
||||
target++
|
||||
) {
|
||||
const weight = weights[target] ?? 0;
|
||||
if (!weight) continue;
|
||||
for (const [semantic, values] of [
|
||||
["POSITION", positions],
|
||||
["NORMAL", normals],
|
||||
["TANGENT", tangents],
|
||||
] as const) {
|
||||
const targetAccessor = primitive.targets[target][semantic];
|
||||
if (targetAccessor === undefined || !values) continue;
|
||||
const delta = accessor(targetAccessor) as Float32Array;
|
||||
if (semantic === "TANGENT") {
|
||||
for (let vertex = 0; vertex < delta.length / 3; vertex++)
|
||||
for (let lane = 0; lane < 3; lane++)
|
||||
values[vertex * 4 + lane] += delta[vertex * 3 + lane] * weight;
|
||||
} else {
|
||||
for (let index = 0; index < delta.length; index++)
|
||||
values[index] += delta[index] * weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jointAccessor = primitive.attributes.JOINTS_0;
|
||||
const weightAccessor = primitive.attributes.WEIGHTS_0;
|
||||
if (
|
||||
jointMatrices &&
|
||||
jointAccessor !== undefined &&
|
||||
weightAccessor !== undefined
|
||||
) {
|
||||
const joints = accessor(jointAccessor, true) as Uint32Array;
|
||||
const weights = accessor(weightAccessor) as Float32Array;
|
||||
for (let vertex = 0; vertex < positions.length / 3; vertex++) {
|
||||
const position = [0, 0, 0];
|
||||
const normal = [0, 0, 0];
|
||||
const tangent = [0, 0, 0];
|
||||
let total = 0;
|
||||
for (let influence = 0; influence < 4; influence++) {
|
||||
const weight = weights[vertex * 4 + influence];
|
||||
const joint = jointMatrices[joints[vertex * 4 + influence]];
|
||||
if (!weight || !joint) continue;
|
||||
const skinnedPosition = point(
|
||||
joint,
|
||||
positions.subarray(vertex * 3, vertex * 3 + 3),
|
||||
);
|
||||
const skinnedNormal = normals
|
||||
? direction(joint, normals.subarray(vertex * 3, vertex * 3 + 3))
|
||||
: undefined;
|
||||
const skinnedTangent = tangents
|
||||
? vector(joint, tangents.subarray(vertex * 4, vertex * 4 + 3))
|
||||
: undefined;
|
||||
for (let lane = 0; lane < 3; lane++) {
|
||||
position[lane] += skinnedPosition[lane] * weight;
|
||||
if (skinnedNormal) normal[lane] += skinnedNormal[lane] * weight;
|
||||
if (skinnedTangent)
|
||||
tangent[lane] += skinnedTangent[lane] * weight;
|
||||
}
|
||||
total += weight;
|
||||
}
|
||||
if (!total) continue;
|
||||
positions.set(
|
||||
position.map((value) => value / total),
|
||||
vertex * 3,
|
||||
);
|
||||
if (normals) {
|
||||
const length = Math.hypot(...normal) || 1;
|
||||
normals.set(
|
||||
normal.map((value) => value / length),
|
||||
vertex * 3,
|
||||
);
|
||||
}
|
||||
if (tangents) {
|
||||
const length = Math.hypot(...tangent) || 1;
|
||||
tangents.set(
|
||||
tangent.map((value) => value / length),
|
||||
vertex * 4,
|
||||
);
|
||||
}
|
||||
}
|
||||
matrix = identity;
|
||||
}
|
||||
|
||||
const mirrored = determinant(matrix) < 0;
|
||||
for (let vertex = 0; vertex < positions.length; vertex += 3)
|
||||
positions.set(
|
||||
point(matrix, positions.subarray(vertex, vertex + 3)),
|
||||
vertex,
|
||||
);
|
||||
if (normals)
|
||||
for (let vertex = 0; vertex < normals.length; vertex += 3)
|
||||
normals.set(
|
||||
direction(matrix, normals.subarray(vertex, vertex + 3)),
|
||||
vertex,
|
||||
);
|
||||
if (tangents)
|
||||
for (let vertex = 0; vertex < tangents.length; vertex += 4) {
|
||||
tangents.set(
|
||||
vector(matrix, tangents.subarray(vertex, vertex + 3)),
|
||||
vertex,
|
||||
);
|
||||
if (mirrored) tangents[vertex + 3] *= -1;
|
||||
}
|
||||
if (mirrored)
|
||||
for (let face = 0; face + 2 < indices.length; face += 3)
|
||||
[indices[face + 1], indices[face + 2]] = [
|
||||
indices[face + 2],
|
||||
indices[face + 1],
|
||||
];
|
||||
normals ??= computeNormals(positions, indices);
|
||||
const material = materials[primitive.material ?? -1];
|
||||
if (!tangents && material?.normalTexture >= 0)
|
||||
tangents = computeTangents(positions, normals, uvs, indices);
|
||||
const colorAccessor = primitive.attributes.COLOR_0;
|
||||
primitives.push({
|
||||
positions,
|
||||
normals,
|
||||
tangents,
|
||||
uvs,
|
||||
uvs2,
|
||||
indices,
|
||||
...(primitive.attributes.NORMAL === undefined
|
||||
...(colorAccessor === 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) }),
|
||||
: {
|
||||
colors: color4(
|
||||
accessor(colorAccessor) as Float32Array,
|
||||
widths[document.accessors[colorAccessor].type],
|
||||
),
|
||||
}),
|
||||
material: primitive.material ?? -1,
|
||||
...transform,
|
||||
position: [0, 0, 0],
|
||||
rotor: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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],
|
||||
rotor: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
) => {
|
||||
const node = nodes[id] ?? {};
|
||||
const world = compose(parent, transform(node));
|
||||
if (node.mesh !== undefined) emitMesh(node.mesh, world);
|
||||
for (const child of node.children ?? []) visit(child, world);
|
||||
const emitNode = (id: number) => {
|
||||
const node = nodes[id];
|
||||
if (node.mesh === undefined) return;
|
||||
const instances = node.extensions?.EXT_mesh_gpu_instancing?.attributes;
|
||||
if (!instances) return emitMesh(node.mesh, id, world(id));
|
||||
const translations =
|
||||
instances.TRANSLATION === undefined
|
||||
? undefined
|
||||
: (accessor(instances.TRANSLATION) as Float32Array);
|
||||
const rotations =
|
||||
instances.ROTATION === undefined
|
||||
? undefined
|
||||
: (accessor(instances.ROTATION) as Float32Array);
|
||||
const scales =
|
||||
instances.SCALE === undefined
|
||||
? undefined
|
||||
: (accessor(instances.SCALE) as Float32Array);
|
||||
const count = translations
|
||||
? translations.length / 3
|
||||
: rotations
|
||||
? rotations.length / 4
|
||||
: scales
|
||||
? scales.length / 3
|
||||
: 0;
|
||||
for (let instance = 0; instance < count; instance++)
|
||||
emitMesh(
|
||||
node.mesh,
|
||||
id,
|
||||
multiply(
|
||||
world(id),
|
||||
transform({
|
||||
translation: translations?.subarray(instance * 3, instance * 3 + 3),
|
||||
rotation: rotations?.subarray(instance * 4, instance * 4 + 4),
|
||||
scale: scales?.subarray(instance * 3, instance * 3 + 3),
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
for (const root of roots) visit(root);
|
||||
for (let id = 0; id < nodes.length; id++) emitNode(id);
|
||||
if (!nodes.length)
|
||||
for (let id = 0; id < (document.meshes ?? []).length; id++)
|
||||
emitMesh(id, {});
|
||||
return { materials, primitives, textures };
|
||||
emitMesh(id, -1, identity);
|
||||
const lightDefinitions =
|
||||
document.extensions?.KHR_lights_punctual?.lights ?? [];
|
||||
const lights = nodes.flatMap((node: any, id: number) => {
|
||||
const lightId = node.extensions?.KHR_lights_punctual?.light;
|
||||
const light = lightId === undefined ? undefined : lightDefinitions[lightId];
|
||||
if (!light) return [];
|
||||
const matrix = world(id);
|
||||
return [
|
||||
{
|
||||
type: light.type,
|
||||
color: light.color ?? [1, 1, 1],
|
||||
intensity: light.intensity ?? 1,
|
||||
range: light.range ?? 1_000_000,
|
||||
innerAngle: light.spot?.innerConeAngle ?? 0,
|
||||
outerAngle: light.spot?.outerConeAngle ?? Math.PI / 4,
|
||||
position: [matrix[12], matrix[13], matrix[14]],
|
||||
rotor: rotor(matrix),
|
||||
},
|
||||
];
|
||||
});
|
||||
const cameraNode = nodes.findIndex((node: any) => node.camera !== undefined);
|
||||
const cameraDefinition =
|
||||
cameraNode < 0 ? undefined : document.cameras?.[nodes[cameraNode].camera];
|
||||
const camera = cameraDefinition
|
||||
? {
|
||||
direction: vector(world(cameraNode), [0, 0, 1]),
|
||||
fov: cameraDefinition.perspective?.yfov,
|
||||
}
|
||||
: undefined;
|
||||
return { camera, lights, materials, primitives, textures };
|
||||
}
|
||||
|
||||
if (typeof addEventListener === "function")
|
||||
addEventListener("message", async ({ data }) => {
|
||||
try {
|
||||
const result = await load(data.url);
|
||||
const result = await loadGltf(data.url);
|
||||
const transfers = result.primitives.flatMap((primitive: any) =>
|
||||
["positions", "indices", "normals", "tangents", "uvs", "colors"]
|
||||
["positions", "indices", "normals", "tangents", "uvs", "uvs2", "colors"]
|
||||
.map((name) => primitive[name]?.buffer)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export class DirectionalLight extends Node {
|
||||
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]);
|
||||
await scene.setLight("directional", this.id, true);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
@@ -21,6 +22,7 @@ export class DirectionalLight extends Node {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.scene.array("directionalLights").row(this.id).fill(0);
|
||||
await this.scene.setLight("directional", this.id, false);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,69 @@
|
||||
import { Node, type NodeOptions } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type PointLightOptions = NodeOptions & { color?: ArrayLike<number>; intensity?: number; range?: number };
|
||||
export type PointLightOptions = NodeOptions & {
|
||||
color?: ArrayLike<number>;
|
||||
intensity?: number;
|
||||
range?: number;
|
||||
radius?: 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]);
|
||||
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,
|
||||
options.radius ?? 0,
|
||||
0,
|
||||
]);
|
||||
await scene.setLight("point", this.id, true);
|
||||
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; }
|
||||
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;
|
||||
}
|
||||
get radius() {
|
||||
return this.scene.array("pointLights").row(this.id)[6];
|
||||
}
|
||||
set radius(value: number) {
|
||||
this.scene.array("pointLights").row(this.id)[6] = value;
|
||||
}
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.scene.array("pointLights").row(this.id).fill(0);
|
||||
await this.scene.setLight("point", this.id, false);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export class SpotLight extends Node {
|
||||
...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1,
|
||||
options.range ?? 10, options.innerAngle ?? 0.35, options.outerAngle ?? 0.7, 1,
|
||||
]);
|
||||
await scene.setLight("spot", this.id, true);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
@@ -32,6 +33,7 @@ export class SpotLight extends Node {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.scene.array("spotLights").row(this.id).fill(0);
|
||||
await this.scene.setLight("spot", this.id, false);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { Scene } from "../Scene";
|
||||
import type { Texture } from "./Texture";
|
||||
|
||||
export type PBRTextureInfo = {
|
||||
texCoord?: number;
|
||||
offset?: ArrayLike<number>;
|
||||
scale?: ArrayLike<number>;
|
||||
rotation?: number;
|
||||
};
|
||||
|
||||
export type PBRMaterialOptions = {
|
||||
baseColor?: ArrayLike<number>;
|
||||
metallic?: number;
|
||||
@@ -12,9 +19,40 @@ export type PBRMaterialOptions = {
|
||||
metallicRoughnessTexture?: Texture;
|
||||
normalTexture?: Texture;
|
||||
emissiveTexture?: Texture;
|
||||
occlusionTexture?: Texture;
|
||||
transmissionTexture?: Texture;
|
||||
thicknessTexture?: Texture;
|
||||
clearcoatTexture?: Texture;
|
||||
iridescenceTexture?: Texture;
|
||||
iridescenceThicknessTexture?: Texture;
|
||||
anisotropyTexture?: Texture;
|
||||
specularTexture?: Texture;
|
||||
specularColorTexture?: Texture;
|
||||
textureInfos?: ArrayLike<PBRTextureInfo | undefined>;
|
||||
flags?: number;
|
||||
occlusionStrength?: number;
|
||||
transmission?: number;
|
||||
ior?: number;
|
||||
thickness?: number;
|
||||
attenuationDistance?: number;
|
||||
attenuationColor?: ArrayLike<number>;
|
||||
emissiveStrength?: number;
|
||||
clearcoat?: number;
|
||||
clearcoatRoughness?: number;
|
||||
specular?: number;
|
||||
sheenColor?: ArrayLike<number>;
|
||||
sheenRoughness?: number;
|
||||
specularColor?: ArrayLike<number>;
|
||||
iridescence?: number;
|
||||
iridescenceIor?: number;
|
||||
iridescenceThicknessMinimum?: number;
|
||||
iridescenceThicknessMaximum?: number;
|
||||
anisotropy?: number;
|
||||
anisotropyRotation?: number;
|
||||
specularExtensionColor?: ArrayLike<number>;
|
||||
};
|
||||
|
||||
/** Conventional PBR values and texture IDs stored in two shared SOA rows. */
|
||||
/** Conventional PBR values, extensions, texture IDs, and UV transforms. */
|
||||
export class PBRMaterial {
|
||||
readonly scene: Scene;
|
||||
id = -1;
|
||||
@@ -28,48 +66,152 @@ export class PBRMaterial {
|
||||
options.metallicRoughnessTexture,
|
||||
options.normalTexture,
|
||||
options.emissiveTexture,
|
||||
options.occlusionTexture,
|
||||
options.transmissionTexture,
|
||||
options.thicknessTexture,
|
||||
options.clearcoatTexture,
|
||||
options.iridescenceTexture,
|
||||
options.iridescenceThicknessTexture,
|
||||
options.anisotropyTexture,
|
||||
options.specularTexture,
|
||||
options.specularColorTexture,
|
||||
].filter((texture): texture is Texture => texture !== undefined);
|
||||
this.ready = Promise.all(textures.map((texture) => texture.ready)).then(async () => {
|
||||
this.ready = Promise.all(textures.map((texture) => texture.ready)).then(
|
||||
async () => {
|
||||
const id = await scene.allocateMaterial();
|
||||
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, [
|
||||
const attenuation = Array.from(options.attenuationColor ?? [1, 1, 1]);
|
||||
const secondary = Array.from(
|
||||
options.flags && options.flags & 16
|
||||
? (options.specularColor ?? [1, 1, 1])
|
||||
: (options.sheenColor ?? [0, 0, 0]),
|
||||
);
|
||||
const specularExtensionColor = Array.from(
|
||||
options.specularExtensionColor ?? [1, 1, 1],
|
||||
);
|
||||
if (
|
||||
color.length !== 4 ||
|
||||
emissive.length !== 3 ||
|
||||
attenuation.length !== 3 ||
|
||||
secondary.length !== 3 ||
|
||||
specularExtensionColor.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,
|
||||
options.flags ?? 0,
|
||||
]);
|
||||
scene.array("materialTextures").write(id, [
|
||||
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,
|
||||
(options.occlusionTexture?.id ?? -1) + 1,
|
||||
(options.transmissionTexture?.id ?? -1) + 1,
|
||||
(options.thicknessTexture?.id ?? -1) + 1,
|
||||
(options.clearcoatTexture?.id ?? -1) + 1,
|
||||
(options.iridescenceTexture?.id ?? -1) + 1,
|
||||
(options.iridescenceThicknessTexture?.id ?? -1) + 1,
|
||||
(options.anisotropyTexture?.id ?? -1) + 1,
|
||||
(options.specularTexture?.id ?? -1) + 1,
|
||||
(options.specularColorTexture?.id ?? -1) + 1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
]);
|
||||
scene
|
||||
.array("materialExtensions")
|
||||
.write(id, [
|
||||
options.transmission ?? 0,
|
||||
options.ior ?? 1.5,
|
||||
options.thickness ?? 0,
|
||||
options.attenuationDistance ?? 0,
|
||||
...attenuation,
|
||||
options.emissiveStrength ?? 1,
|
||||
options.clearcoat ?? 0,
|
||||
options.clearcoatRoughness ?? 0,
|
||||
options.specular ?? 1,
|
||||
options.occlusionStrength ?? 1,
|
||||
...secondary,
|
||||
options.sheenRoughness ?? 0,
|
||||
options.iridescence ?? 0,
|
||||
options.iridescenceIor ?? 1.3,
|
||||
options.iridescenceThicknessMinimum ?? 100,
|
||||
options.iridescenceThicknessMaximum ?? 400,
|
||||
options.anisotropy ?? 0,
|
||||
options.anisotropyRotation ?? 0,
|
||||
0,
|
||||
0,
|
||||
...specularExtensionColor,
|
||||
0,
|
||||
]);
|
||||
const transforms = new Float32Array(104);
|
||||
for (let slot = 0; slot < 13; slot++) {
|
||||
const info = options.textureInfos?.[slot];
|
||||
const offset = Array.from(info?.offset ?? [0, 0]);
|
||||
const scale = Array.from(info?.scale ?? [1, 1]);
|
||||
if (offset.length !== 2 || scale.length !== 2)
|
||||
throw new TypeError("PBR texture transform");
|
||||
const rotation = info?.rotation ?? 0;
|
||||
transforms.set(
|
||||
[
|
||||
offset[0],
|
||||
offset[1],
|
||||
scale[0],
|
||||
scale[1],
|
||||
Math.sin(rotation),
|
||||
Math.cos(rotation),
|
||||
info?.texCoord ?? 0,
|
||||
0,
|
||||
],
|
||||
slot * 8,
|
||||
);
|
||||
}
|
||||
scene.array("materialTextureInfo").write(id, transforms);
|
||||
return this;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#values() {
|
||||
if (this.id < 0) throw new Error("Await material.ready before reading or writing it");
|
||||
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); }
|
||||
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); }
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Scene } from "../Scene";
|
||||
import type { GraphSampler } from "../ComputePass";
|
||||
|
||||
export type TextureOptions = {
|
||||
source?: string | ImageBitmap;
|
||||
@@ -7,6 +8,7 @@ export type TextureOptions = {
|
||||
usage?: string[];
|
||||
mipmaps?: boolean;
|
||||
transient?: boolean;
|
||||
sampler?: Omit<GraphSampler, "id">;
|
||||
};
|
||||
|
||||
let nextTextureName = 1;
|
||||
@@ -52,6 +54,7 @@ export class Texture {
|
||||
]),
|
||||
],
|
||||
transient: options.transient ?? false,
|
||||
sampler: options.sampler,
|
||||
});
|
||||
this.id = registration.number;
|
||||
await registration.ready;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
type RowFormat = "f32" | "u32" | "i32";
|
||||
type RowDescriptor = {
|
||||
name: string;
|
||||
rows: number;
|
||||
stride: number;
|
||||
format: RowFormat;
|
||||
};
|
||||
|
||||
const views = {
|
||||
f32: Float32Array,
|
||||
u32: Uint32Array,
|
||||
i32: Int32Array,
|
||||
};
|
||||
|
||||
export class SharedRows {
|
||||
readonly name: string;
|
||||
rows: number;
|
||||
readonly stride: number;
|
||||
readonly format: RowFormat;
|
||||
buffer: SharedArrayBuffer;
|
||||
|
||||
constructor({ name, rows, stride, format }: RowDescriptor) {
|
||||
this.name = name;
|
||||
this.rows = rows;
|
||||
this.stride = stride;
|
||||
this.format = format;
|
||||
this.buffer = new SharedArrayBuffer(rows * stride);
|
||||
}
|
||||
|
||||
resize(rows: number) {
|
||||
if (rows <= this.rows) return this;
|
||||
const previous = this.view;
|
||||
this.rows = rows;
|
||||
this.buffer = new SharedArrayBuffer(rows * this.stride);
|
||||
this.view.set(previous);
|
||||
return this;
|
||||
}
|
||||
|
||||
get view() {
|
||||
const View = views[this.format];
|
||||
return new View(this.buffer);
|
||||
}
|
||||
|
||||
row(index: number) {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= this.rows)
|
||||
throw new RangeError(`ROW_RANGE: ${this.name}[${index}]`);
|
||||
const width = this.stride / views[this.format].BYTES_PER_ELEMENT;
|
||||
return this.view.subarray(index * width, (index + 1) * width);
|
||||
}
|
||||
|
||||
write(index: number, values: ArrayLike<number>) {
|
||||
const row = this.row(index);
|
||||
if (values.length !== row.length) throw new RangeError("ROW_WIDTH");
|
||||
row.set(values);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
function deserialize(serialized: string) {
|
||||
let offset = 0;
|
||||
const skip = () => {
|
||||
while (/\s/u.test(serialized[offset] ?? "")) offset++;
|
||||
};
|
||||
const take = (character: string) => {
|
||||
skip();
|
||||
if (serialized[offset] !== character)
|
||||
throw new SyntaxError(`Expected ${character} at ${offset}`);
|
||||
offset++;
|
||||
};
|
||||
const word = () => {
|
||||
skip();
|
||||
const start = offset;
|
||||
while (offset < serialized.length && !/[\s()]/u.test(serialized[offset]))
|
||||
offset++;
|
||||
return serialized.slice(start, offset);
|
||||
};
|
||||
const value = (): any => {
|
||||
skip();
|
||||
if (serialized[offset] === '"') {
|
||||
const start = offset++;
|
||||
while (offset < serialized.length) {
|
||||
if (serialized[offset] === "\\") offset += 2;
|
||||
else if (serialized[offset++] === '"') break;
|
||||
}
|
||||
return JSON.parse(serialized.slice(start, offset));
|
||||
}
|
||||
if (serialized[offset] !== "(") {
|
||||
const token = word();
|
||||
if (token === "true") return true;
|
||||
if (token === "false") return false;
|
||||
if (token === "null") return null;
|
||||
const number = Number(token);
|
||||
if (!Number.isNaN(number)) return number;
|
||||
throw new SyntaxError(`Unknown value ${token}`);
|
||||
}
|
||||
take("(");
|
||||
const tag = word();
|
||||
if (tag === "array") {
|
||||
const result = [];
|
||||
skip();
|
||||
while (serialized[offset] !== ")") result.push(value());
|
||||
take(")");
|
||||
return result;
|
||||
}
|
||||
if (tag === "object") {
|
||||
const result: Record<string, unknown> = {};
|
||||
skip();
|
||||
while (serialized[offset] !== ")") {
|
||||
take("(");
|
||||
if (word() !== "field") throw new SyntaxError("Expected field");
|
||||
const key = value();
|
||||
result[key] = value();
|
||||
take(")");
|
||||
skip();
|
||||
}
|
||||
take(")");
|
||||
return result;
|
||||
}
|
||||
if (tag === "yawn-graph") {
|
||||
if (word() !== "1") throw new SyntaxError("Graph version");
|
||||
const result = value();
|
||||
take(")");
|
||||
return result;
|
||||
}
|
||||
throw new SyntaxError(`Unknown list ${tag}`);
|
||||
};
|
||||
return value();
|
||||
}
|
||||
|
||||
export class YawnCore {
|
||||
readonly ready = Promise.resolve();
|
||||
readonly features = new Set(["timestamp-query", "indirect-first-instance"]);
|
||||
readonly graphs: any[] = [];
|
||||
readonly serializedGraphs: string[] = [];
|
||||
readonly loadouts: string[] = [];
|
||||
readonly uploadedTextures: { name: string; mipLevel: number }[] = [];
|
||||
fps?: number;
|
||||
disposed = false;
|
||||
#arrays = new Map<string, SharedRows>();
|
||||
#nextObjects = new Map<string, number>();
|
||||
|
||||
constructor() {
|
||||
this.#arrays.set(
|
||||
"signals",
|
||||
new SharedRows({ name: "signals", rows: 1, stride: 32, format: "f32" }),
|
||||
);
|
||||
}
|
||||
|
||||
createRows(descriptor: RowDescriptor) {
|
||||
const current = this.#arrays.get(descriptor.name);
|
||||
if (current) {
|
||||
if (
|
||||
current.stride !== descriptor.stride ||
|
||||
current.format !== descriptor.format
|
||||
)
|
||||
throw new Error(`ROW_LAYOUT: ${descriptor.name}`);
|
||||
return Promise.resolve(current.resize(descriptor.rows));
|
||||
}
|
||||
const rows = new SharedRows(descriptor);
|
||||
this.#arrays.set(descriptor.name, rows);
|
||||
return Promise.resolve(rows);
|
||||
}
|
||||
|
||||
createRowsBatch(descriptors: RowDescriptor[]) {
|
||||
return Promise.all(
|
||||
descriptors.map((descriptor) => this.createRows(descriptor)),
|
||||
);
|
||||
}
|
||||
|
||||
deleteRows(name: string) {
|
||||
this.#arrays.delete(name);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
allocateObject(name: string, count = 1) {
|
||||
const id = this.#nextObjects.get(name) ?? 0;
|
||||
this.#nextObjects.set(name, id + count);
|
||||
const rows = this.array(name);
|
||||
rows.resize(id + count);
|
||||
return Promise.resolve(id);
|
||||
}
|
||||
|
||||
deleteObject() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
syncRows() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
array(name: string) {
|
||||
const rows = this.#arrays.get(name);
|
||||
if (!rows) throw new Error(`UNKNOWN_ARRAY: ${name}`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
compileGraph(serialized: string) {
|
||||
this.serializedGraphs.push(serialized);
|
||||
this.graphs.push(deserialize(serialized));
|
||||
return Promise.resolve(`loadout-${this.graphs.length}`);
|
||||
}
|
||||
|
||||
switchLoadout(id: string) {
|
||||
this.loadouts.push(id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
setFps(fps: number) {
|
||||
this.fps = fps;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
uploadTexture(name: string, _image: unknown, mipLevel = 0) {
|
||||
this.uploadedTextures.push({ name, mipLevel });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
deleteTexture() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { Mesh } from "../src/Mesh";
|
||||
import { Node } from "../src/Node";
|
||||
import { ArcRotateCamera } from "../src/camera/ArcRotateCamera";
|
||||
import { PointLight } from "../src/lights/PointLight";
|
||||
import { PBRMaterial } from "../src/materials/PBRMaterial";
|
||||
import type { Texture } from "../src/materials/Texture";
|
||||
import {
|
||||
bucketDraws,
|
||||
createScene,
|
||||
latestGraph,
|
||||
testCore,
|
||||
triangle,
|
||||
} from "./scene-helpers";
|
||||
|
||||
function close(actual: ArrayLike<number>, expected: ArrayLike<number>) {
|
||||
assert.equal(actual.length, expected.length);
|
||||
for (let index = 0; index < actual.length; index++)
|
||||
assert.ok(
|
||||
Math.abs(actual[index] - expected[index]) < 1e-5,
|
||||
`${actual[index]} != ${expected[index]} at ${index}`,
|
||||
);
|
||||
}
|
||||
|
||||
test("PBRMaterial packs factors, extensions, texture pointers, and UV transforms", async () => {
|
||||
const scene = await createScene();
|
||||
const textures = Array.from(
|
||||
{ length: 13 },
|
||||
(_, id) => ({ id, ready: Promise.resolve() }) as unknown as Texture,
|
||||
);
|
||||
const material = new PBRMaterial(scene, {
|
||||
baseColor: [0.1, 0.2, 0.3, 0.4],
|
||||
metallic: 0.5,
|
||||
roughness: 0.6,
|
||||
emissive: [0.7, 0.8, 0.9],
|
||||
normalScale: 0.75,
|
||||
alphaCutoff: 0.25,
|
||||
flags: 6,
|
||||
baseColorTexture: textures[0],
|
||||
metallicRoughnessTexture: textures[1],
|
||||
normalTexture: textures[2],
|
||||
emissiveTexture: textures[3],
|
||||
occlusionTexture: textures[4],
|
||||
transmissionTexture: textures[5],
|
||||
thicknessTexture: textures[6],
|
||||
clearcoatTexture: textures[7],
|
||||
iridescenceTexture: textures[8],
|
||||
iridescenceThicknessTexture: textures[9],
|
||||
anisotropyTexture: textures[10],
|
||||
specularTexture: textures[11],
|
||||
specularColorTexture: textures[12],
|
||||
transmission: 0.8,
|
||||
ior: 1.6,
|
||||
thickness: 0.4,
|
||||
attenuationDistance: 3,
|
||||
attenuationColor: [0.1, 0.2, 0.3],
|
||||
emissiveStrength: 4,
|
||||
clearcoat: 0.5,
|
||||
clearcoatRoughness: 0.6,
|
||||
specular: 0.7,
|
||||
occlusionStrength: 0.8,
|
||||
sheenColor: [0.9, 0.1, 0.2],
|
||||
sheenRoughness: 0.3,
|
||||
iridescence: 0.4,
|
||||
iridescenceIor: 1.4,
|
||||
iridescenceThicknessMinimum: 120,
|
||||
iridescenceThicknessMaximum: 380,
|
||||
anisotropy: 0.5,
|
||||
anisotropyRotation: 0.6,
|
||||
specularExtensionColor: [0.7, 0.8, 0.9],
|
||||
textureInfos: [
|
||||
{
|
||||
offset: [0.1, 0.2],
|
||||
scale: [0.3, 0.4],
|
||||
rotation: 0.5,
|
||||
texCoord: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
await material.ready;
|
||||
|
||||
close(
|
||||
scene.array("materials").row(material.id),
|
||||
[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.75, 0.25, 6],
|
||||
);
|
||||
assert.deepEqual(
|
||||
Array.from(scene.array("materialTextures").row(material.id)),
|
||||
[...Array.from({ length: 13 }, (_, index) => index + 1), 0, 0, 0],
|
||||
);
|
||||
close(
|
||||
scene.array("materialExtensions").row(material.id),
|
||||
[
|
||||
0.8, 1.6, 0.4, 3, 0.1, 0.2, 0.3, 4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.1, 0.2,
|
||||
0.3, 0.4, 1.4, 120, 380, 0.5, 0.6, 0, 0, 0.7, 0.8, 0.9, 0,
|
||||
],
|
||||
);
|
||||
const transforms = scene.array("materialTextureInfo").row(material.id);
|
||||
close(transforms.subarray(0, 8), [
|
||||
0.1,
|
||||
0.2,
|
||||
0.3,
|
||||
0.4,
|
||||
Math.sin(0.5),
|
||||
Math.cos(0.5),
|
||||
1,
|
||||
0,
|
||||
]);
|
||||
close(transforms.subarray(8, 16), [0, 0, 1, 1, 0, 1, 0, 0]);
|
||||
});
|
||||
|
||||
test("hot material setters write shared rows without rebuilding the graph", async () => {
|
||||
const scene = await createScene();
|
||||
const material = new PBRMaterial(scene);
|
||||
await material.ready;
|
||||
const core = testCore(scene);
|
||||
const compiled = core.graphs.length;
|
||||
core.array("signals").row(0).fill(0);
|
||||
|
||||
material.baseColor = [0.2, 0.3, 0.4, 0.5];
|
||||
material.metallic = 0.6;
|
||||
material.roughness = 0.7;
|
||||
material.emissive = [0.8, 0.9, 1];
|
||||
|
||||
close(
|
||||
scene.array("materials").row(material.id),
|
||||
[0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1, 0.5, 0],
|
||||
);
|
||||
assert.equal(core.array("signals").row(0)[5], 1);
|
||||
assert.equal(core.graphs.length, compiled);
|
||||
});
|
||||
|
||||
test("Mesh packs geometry and bounds, then clones with copy-on-write", async () => {
|
||||
const scene = await createScene();
|
||||
const firstMaterial = new PBRMaterial(scene);
|
||||
const secondMaterial = new PBRMaterial(scene, { baseColor: [1, 0, 0, 1] });
|
||||
await Promise.all([firstMaterial.ready, secondMaterial.ready]);
|
||||
const mesh = new Mesh(scene, {
|
||||
material: firstMaterial,
|
||||
vertexData: triangle,
|
||||
});
|
||||
await mesh.ready;
|
||||
|
||||
assert.equal(mesh.vertexCount, 3);
|
||||
assert.equal(mesh.indexCount, 3);
|
||||
close(scene.geometryData(mesh.geometryId, "positions")!, triangle.positions);
|
||||
close(scene.array("bounds").row(mesh.id), [-1, -1, 0, 0, 1, 1, 0, 0]);
|
||||
// Vertex 1 lives in the shared interleaved array: position, normal, uv, uv2, tangent, colour.
|
||||
const geometry = scene.geometry(mesh.geometryId);
|
||||
close(
|
||||
scene.array("vertices").row(geometry.vertexRow + 1),
|
||||
[1, -1, 0, 0, 0, 0, 1, 0, 1, 0, 0.9, 0.1, 1, 0, 0, 1, 0, 1, 0, 1],
|
||||
);
|
||||
close(scene.array("indices").row(geometry.indexRow), [0, 1, 2, 0]);
|
||||
|
||||
const clone = mesh.clone({ position: [2, 0, 0] });
|
||||
await clone.ready;
|
||||
assert.equal(clone.geometryId, mesh.geometryId);
|
||||
await clone.setVertexData("positions", [0, 0, 0, 2, 0, 0, 0, 3, 0]);
|
||||
assert.notEqual(clone.geometryId, mesh.geometryId);
|
||||
close(scene.geometryData(mesh.geometryId, "positions")!, triangle.positions);
|
||||
close(
|
||||
scene.geometryData(clone.geometryId, "positions")!,
|
||||
[0, 0, 0, 2, 0, 0, 0, 3, 0],
|
||||
);
|
||||
|
||||
clone.material = secondMaterial;
|
||||
await clone.setMaterialForFaces(firstMaterial, 0);
|
||||
assert.equal(clone.materialId, secondMaterial.id);
|
||||
assert.equal(clone.faceMaterials.get(0), firstMaterial.id);
|
||||
});
|
||||
|
||||
test("geometry spans validate before mutating, move intact, and inhibit rendering until published", async () => {
|
||||
const scene = await createScene();
|
||||
const material = new PBRMaterial(scene);
|
||||
await material.ready;
|
||||
const first = new Mesh(scene, { material, vertexData: triangle });
|
||||
const second = new Mesh(scene, { material, vertexData: triangle });
|
||||
await Promise.all([first.ready, second.ready]);
|
||||
const core = testCore(scene);
|
||||
const before = {
|
||||
graphs: core.graphs.length,
|
||||
first: { ...scene.geometry(first.geometryId) },
|
||||
second: { ...scene.geometry(second.geometryId) },
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
first.setVertexData("indices", [0, 1, 3]),
|
||||
/VERTEX_DATA: indices\[2\]/u,
|
||||
);
|
||||
await assert.rejects(
|
||||
first.setVertexData("indices", [0, 1, 1.5]),
|
||||
/VERTEX_DATA: indices\[2\]/u,
|
||||
);
|
||||
await assert.rejects(
|
||||
first.setVertexData("normals", [0, 0, 1]),
|
||||
/VERTEX_DATA: normals/u,
|
||||
);
|
||||
assert.deepEqual({ ...scene.geometry(first.geometryId) }, before.first);
|
||||
close(scene.geometryData(first.geometryId, "indices")!, [0, 1, 2]);
|
||||
assert.equal(core.graphs.length, before.graphs);
|
||||
|
||||
// Hold the older loadout switch open while the first mesh grows past its span.
|
||||
let releaseSwitch!: () => void;
|
||||
const switching = new Promise<void>((resolve) => (releaseSwitch = resolve));
|
||||
const switchLoadout = core.switchLoadout.bind(core);
|
||||
core.switchLoadout = (id) => switching.then(() => switchLoadout(id));
|
||||
const older = scene.updateRenderGraph();
|
||||
const grown = first.setVertexData({
|
||||
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0],
|
||||
normals: [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
|
||||
tangents: Array(16).fill(0),
|
||||
uvs: [0, 0, 1, 0, 0, 1, 1, 1],
|
||||
uvs2: [0, 0, 1, 0, 0, 1, 1, 1],
|
||||
colors: Array(16).fill(1),
|
||||
indices: [0, 1, 2, 2, 1, 3],
|
||||
});
|
||||
const signals = core.array("signals").row(0);
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
assert.equal(signals[4], 1);
|
||||
assert.equal(core.loadouts.length, before.graphs);
|
||||
releaseSwitch();
|
||||
await older;
|
||||
assert.equal(signals[4], 1);
|
||||
core.switchLoadout = switchLoadout;
|
||||
await grown;
|
||||
assert.equal(signals[4], 0);
|
||||
assert.equal(signals[5], 1);
|
||||
|
||||
const moved = scene.geometry(first.geometryId);
|
||||
assert.equal(moved.vertexCount, 4);
|
||||
assert.notEqual(moved.vertexRow, before.first.vertexRow);
|
||||
assert.deepEqual({ ...scene.geometry(second.geometryId) }, before.second);
|
||||
close(
|
||||
scene.array("vertices").row(before.second.vertexRow + 1),
|
||||
[1, -1, 0, 0, 0, 0, 1, 0, 1, 0, 0.9, 0.1, 1, 0, 0, 1, 0, 1, 0, 1],
|
||||
);
|
||||
close(scene.array("indices").row(moved.indexRow), [0, 1, 2, 2]);
|
||||
const draws = bucketDraws(scene, latestGraph(scene));
|
||||
assert.deepEqual(
|
||||
draws.map((draw) => [draw.baseVertex, draw.firstIndex, draw.indices]),
|
||||
[
|
||||
[moved.vertexRow, moved.indexRow * 4, 6],
|
||||
[before.second.vertexRow, before.second.indexRow * 4, 3],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("Node, ArcRotateCamera, and light handles update their shared rows", async () => {
|
||||
const scene = await createScene();
|
||||
const target = new Node(scene, { position: [1, 2, 3], scale: [2, 3, 4] });
|
||||
await target.ready;
|
||||
target.translate([1, -1, 2]).rotateZ(Math.PI);
|
||||
close(target.position, [2, 1, 5]);
|
||||
close(target.scale, [2, 3, 4]);
|
||||
close(target.rotor, [0, 0, 1, 0]);
|
||||
|
||||
const child = new Node(scene, { parent: target });
|
||||
await child.ready;
|
||||
assert.equal(child.parentId, target.id);
|
||||
child.enabled = false;
|
||||
assert.equal(scene.array("nodes").row(child.id)[0], 0);
|
||||
|
||||
const camera = new ArcRotateCamera(scene, {
|
||||
target,
|
||||
alpha: 0,
|
||||
beta: Math.PI / 2,
|
||||
radius: 5,
|
||||
});
|
||||
await camera.ready;
|
||||
close(camera.position, [2, 1, 10]);
|
||||
assert.equal(scene.array("cameraMatrices").row(camera.cameraId)[19], 1);
|
||||
camera.radius = 3;
|
||||
close(camera.position, [2, 1, 8]);
|
||||
|
||||
const light = new PointLight(scene, {
|
||||
position: [4, 5, 6],
|
||||
color: [0.2, 0.4, 0.6],
|
||||
intensity: 7,
|
||||
range: 8,
|
||||
radius: 2,
|
||||
});
|
||||
await light.ready;
|
||||
close(
|
||||
scene.array("pointLights").row(light.id),
|
||||
[0.2, 0.4, 0.6, 7, 8, 1, 2, 0],
|
||||
);
|
||||
light.intensity = 9;
|
||||
light.range = 10;
|
||||
light.radius = 3;
|
||||
close(scene.array("pointLights").row(light.id).subarray(3, 5), [9, 10]);
|
||||
assert.equal(scene.array("pointLights").row(light.id)[6], 3);
|
||||
});
|
||||
|
||||
test("ArcRotateCamera damps pan, orbit and zoom with reversed vertical orbit", async (t) => {
|
||||
let now = 0;
|
||||
let next = 1;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
const originalRequest = globalThis.requestAnimationFrame;
|
||||
const originalCancel = globalThis.cancelAnimationFrame;
|
||||
t.mock.method(performance, "now", () => now);
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
const id = next++;
|
||||
frames.set(id, callback);
|
||||
return id;
|
||||
};
|
||||
globalThis.cancelAnimationFrame = (id) => {
|
||||
frames.delete(id);
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = originalRequest;
|
||||
globalThis.cancelAnimationFrame = originalCancel;
|
||||
});
|
||||
const advance = (ms: number) => {
|
||||
now += ms;
|
||||
const callbacks = [...frames.values()];
|
||||
frames.clear();
|
||||
for (const callback of callbacks) callback(now);
|
||||
};
|
||||
const listeners = new Map<string, Set<(event: any) => void>>();
|
||||
const element = {
|
||||
clientWidth: 400,
|
||||
clientHeight: 200,
|
||||
addEventListener(type: string, listener: (event: any) => void) {
|
||||
const registered = listeners.get(type) ?? new Set();
|
||||
registered.add(listener);
|
||||
listeners.set(type, registered);
|
||||
},
|
||||
removeEventListener(type: string, listener: (event: any) => void) {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
setPointerCapture() {},
|
||||
} as unknown as HTMLElement;
|
||||
const dispatch = (type: string, event: Record<string, unknown>) => {
|
||||
for (const listener of listeners.get(type) ?? [])
|
||||
listener({ preventDefault() {}, ...event });
|
||||
};
|
||||
const scene = await createScene();
|
||||
const camera = new ArcRotateCamera(scene, {
|
||||
alpha: 0,
|
||||
beta: Math.PI / 2,
|
||||
radius: 10,
|
||||
fov: Math.PI / 2,
|
||||
aspect: 2,
|
||||
controls: { element },
|
||||
});
|
||||
await camera.ready;
|
||||
|
||||
dispatch("pointerdown", {
|
||||
button: 2,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
pointerId: 1,
|
||||
});
|
||||
dispatch("pointermove", { clientX: 120, clientY: 110 });
|
||||
dispatch("pointerup", {});
|
||||
advance(80);
|
||||
close(camera.position, [-2 * (1 - Math.exp(-1)), 1 - Math.exp(-1), 10]);
|
||||
advance(1600);
|
||||
close(camera.position, [-2, 1, 10]);
|
||||
advance(16);
|
||||
assert.equal(frames.size, 0);
|
||||
const orbit = () => {
|
||||
dispatch("pointerdown", {
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
pointerId: 1,
|
||||
});
|
||||
dispatch("pointermove", { clientX: 140, clientY: 120 });
|
||||
dispatch("pointerup", {});
|
||||
};
|
||||
orbit();
|
||||
advance(40);
|
||||
const released = camera.alpha;
|
||||
advance(40);
|
||||
assert.ok(camera.alpha < released);
|
||||
const pose = [camera.alpha, camera.beta];
|
||||
close(pose, [
|
||||
-0.2 * (1 - Math.exp(-1)),
|
||||
Math.PI / 2 - 0.1 * (1 - Math.exp(-1)),
|
||||
]);
|
||||
camera.detachControls();
|
||||
camera.alpha = 0;
|
||||
camera.beta = Math.PI / 2;
|
||||
camera.attachControls({ element });
|
||||
orbit();
|
||||
advance(80);
|
||||
close([camera.alpha, camera.beta], pose);
|
||||
dispatch("wheel", { deltaY: 100 });
|
||||
advance(80);
|
||||
close([camera.radius], [10 * Math.exp(0.1 * (1 - Math.exp(-1)))]);
|
||||
camera.detachControls();
|
||||
assert.equal(frames.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { importGltf } from "../src/importers/gltf";
|
||||
import { createScene, latestGraph, testCore } from "./scene-helpers";
|
||||
|
||||
function close(actual: ArrayLike<number>, expected: ArrayLike<number>) {
|
||||
assert.equal(actual.length, expected.length);
|
||||
for (let index = 0; index < actual.length; index++)
|
||||
assert.ok(Math.abs(actual[index] - expected[index]) < 1e-5);
|
||||
}
|
||||
|
||||
test("importGltf hydrates worker output into one batched scene update", async () => {
|
||||
const previousWorker = globalThis.Worker;
|
||||
const previousImageBitmap = globalThis.ImageBitmap;
|
||||
const previousCreateImageBitmap = globalThis.createImageBitmap;
|
||||
class MockImageBitmap {
|
||||
readonly width = 1;
|
||||
readonly height = 1;
|
||||
}
|
||||
const workerResult = {
|
||||
camera: { direction: [0, 0, 1], fov: 1.1 },
|
||||
textures: [
|
||||
{
|
||||
image: new MockImageBitmap(),
|
||||
sampler: {
|
||||
magFilter: "linear",
|
||||
minFilter: "linear",
|
||||
mipmapFilter: "linear",
|
||||
addressModeU: "repeat",
|
||||
addressModeV: "repeat",
|
||||
},
|
||||
},
|
||||
],
|
||||
materials: [
|
||||
{
|
||||
baseColor: [0.2, 0.4, 0.6, 1],
|
||||
metallic: 0.3,
|
||||
roughness: 0.7,
|
||||
flags: 0,
|
||||
baseColorTexture: 0,
|
||||
metallicRoughnessTexture: -1,
|
||||
normalTexture: -1,
|
||||
emissiveTexture: -1,
|
||||
occlusionTexture: -1,
|
||||
transmissionTexture: -1,
|
||||
thicknessTexture: -1,
|
||||
clearcoatTexture: -1,
|
||||
iridescenceTexture: -1,
|
||||
iridescenceThicknessTexture: -1,
|
||||
anisotropyTexture: -1,
|
||||
specularTexture: -1,
|
||||
specularColorTexture: -1,
|
||||
},
|
||||
],
|
||||
primitives: [
|
||||
{
|
||||
positions: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
||||
normals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]),
|
||||
uvs: new Float32Array([0, 0, 1, 0, 0, 1]),
|
||||
indices: new Uint32Array([0, 1, 2]),
|
||||
material: 0,
|
||||
position: [2, 3, 4],
|
||||
rotor: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
],
|
||||
lights: [
|
||||
{
|
||||
type: "point",
|
||||
color: [1, 0.5, 0.25],
|
||||
intensity: 6,
|
||||
range: 12,
|
||||
position: [4, 5, 6],
|
||||
},
|
||||
],
|
||||
};
|
||||
class MockWorker {
|
||||
#listeners = new Map<string, (event: any) => void>();
|
||||
|
||||
addEventListener(type: string, listener: (event: any) => void) {
|
||||
this.#listeners.set(type, listener);
|
||||
}
|
||||
|
||||
postMessage(message: { request: number }) {
|
||||
queueMicrotask(() =>
|
||||
this.#listeners.get("message")?.({
|
||||
data: { request: message.request, result: workerResult },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Object.defineProperties(globalThis, {
|
||||
Worker: { configurable: true, value: MockWorker },
|
||||
ImageBitmap: { configurable: true, value: MockImageBitmap },
|
||||
createImageBitmap: {
|
||||
configurable: true,
|
||||
value: async () => new MockImageBitmap(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const scene = await createScene();
|
||||
const core = testCore(scene);
|
||||
const compiled = core.graphs.length;
|
||||
const meshes = await importGltf(scene, "https://example.test/model.glb");
|
||||
|
||||
assert.equal(meshes.length, 1);
|
||||
assert.deepEqual(meshes.camera, workerResult.camera);
|
||||
assert.deepEqual(meshes[0].position.toArray(), [2, 3, 4]);
|
||||
assert.deepEqual(
|
||||
Array.from(scene.geometryData(meshes[0].geometryId, "indices")!),
|
||||
[0, 1, 2],
|
||||
);
|
||||
close(
|
||||
scene.array("materials").row(meshes[0].materialId).slice(0, 6),
|
||||
[0.2, 0.4, 0.6, 1, 0.3, 0.7],
|
||||
);
|
||||
assert.equal(
|
||||
scene.array("materialTextures").row(meshes[0].materialId)[0],
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
Array.from(scene.array("pointLights").row(1)),
|
||||
[1, 0.5, 0.25, 6, 12, 1, 0, 0],
|
||||
);
|
||||
const texture = latestGraph(scene).resources.textures.find(
|
||||
(resource: any) => resource.id.startsWith("texture-"),
|
||||
);
|
||||
assert.equal(texture.format, "rgba8unorm-srgb");
|
||||
assert.equal(core.uploadedTextures.length, 1);
|
||||
assert.equal(core.graphs.length - compiled, 1);
|
||||
} finally {
|
||||
for (const [name, previous] of [
|
||||
["Worker", previousWorker],
|
||||
["ImageBitmap", previousImageBitmap],
|
||||
["createImageBitmap", previousCreateImageBitmap],
|
||||
] as const)
|
||||
if (previous === undefined) delete (globalThis as any)[name];
|
||||
else
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
value: previous,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { loadGltf } from "../src/importers/worker";
|
||||
|
||||
function close(actual: ArrayLike<number>, expected: ArrayLike<number>) {
|
||||
assert.equal(actual.length, expected.length);
|
||||
for (let index = 0; index < actual.length; index++)
|
||||
assert.ok(
|
||||
Math.abs(actual[index] - expected[index]) < 1e-5,
|
||||
`${actual[index]} != ${expected[index]} at ${index}`,
|
||||
);
|
||||
}
|
||||
|
||||
function dataUrl(type: string, value: Uint8Array | string) {
|
||||
return `data:${type};base64,${Buffer.from(value).toString("base64")}`;
|
||||
}
|
||||
|
||||
function binaryBuilder() {
|
||||
let bytes = new Uint8Array();
|
||||
return {
|
||||
append(source: ArrayBufferView, alignment: number) {
|
||||
const input = new Uint8Array(
|
||||
source.buffer,
|
||||
source.byteOffset,
|
||||
source.byteLength,
|
||||
);
|
||||
const offset = Math.ceil(bytes.length / alignment) * alignment;
|
||||
const next = new Uint8Array(offset + input.length);
|
||||
next.set(bytes);
|
||||
next.set(input, offset);
|
||||
bytes = next;
|
||||
return { byteOffset: offset, byteLength: input.length };
|
||||
},
|
||||
get bytes() {
|
||||
return bytes;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function glb(document: object, binary: Uint8Array) {
|
||||
const json = new TextEncoder().encode(JSON.stringify(document));
|
||||
const jsonLength = Math.ceil(json.length / 4) * 4;
|
||||
const binaryLength = Math.ceil(binary.length / 4) * 4;
|
||||
const result = new Uint8Array(12 + 8 + jsonLength + 8 + binaryLength);
|
||||
const view = new DataView(result.buffer);
|
||||
view.setUint32(0, 0x46546c67, true);
|
||||
view.setUint32(4, 2, true);
|
||||
view.setUint32(8, result.length, true);
|
||||
view.setUint32(12, jsonLength, true);
|
||||
view.setUint32(16, 0x4e4f534a, true);
|
||||
result.fill(0x20, 20, 20 + jsonLength);
|
||||
result.set(json, 20);
|
||||
const binaryHeader = 20 + jsonLength;
|
||||
view.setUint32(binaryHeader, binaryLength, true);
|
||||
view.setUint32(binaryHeader + 4, 0x004e4942, true);
|
||||
result.set(binary, binaryHeader + 8);
|
||||
return result;
|
||||
}
|
||||
|
||||
test("JSON glTF parsing covers accessors, transforms, materials, lights, and cameras", async () => {
|
||||
const binary = binaryBuilder();
|
||||
const positions = binary.append(
|
||||
new Float32Array([0, 0, 0, 99, 1, 0, 0, 99, 0, 1, 0, 99]),
|
||||
4,
|
||||
);
|
||||
const uvs = binary.append(new Uint16Array([0, 0, 65_535, 0, 0, 65_535]), 2);
|
||||
const colors = binary.append(
|
||||
new Uint8Array([255, 0, 0, 0, 255, 0, 0, 0, 255]),
|
||||
1,
|
||||
);
|
||||
const indices = binary.append(new Uint16Array([0, 1, 2]), 2);
|
||||
const sparseIndices = binary.append(new Uint8Array([1]), 1);
|
||||
const sparsePositions = binary.append(new Float32Array([2, 0, 0]), 4);
|
||||
const texture = { index: 0 };
|
||||
const document = {
|
||||
asset: { version: "2.0" },
|
||||
extensions: {
|
||||
KHR_lights_punctual: {
|
||||
lights: [
|
||||
{
|
||||
type: "spot",
|
||||
color: [0.2, 0.4, 0.6],
|
||||
intensity: 7,
|
||||
range: 8,
|
||||
spot: { innerConeAngle: 0.1, outerConeAngle: 0.5 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
buffers: [
|
||||
{
|
||||
byteLength: binary.bytes.length,
|
||||
uri: dataUrl("application/octet-stream", binary.bytes),
|
||||
},
|
||||
],
|
||||
bufferViews: [
|
||||
{ buffer: 0, ...positions, byteStride: 16 },
|
||||
{ buffer: 0, ...uvs },
|
||||
{ buffer: 0, ...colors },
|
||||
{ buffer: 0, ...indices },
|
||||
{ buffer: 0, ...sparseIndices },
|
||||
{ buffer: 0, ...sparsePositions },
|
||||
],
|
||||
accessors: [
|
||||
{
|
||||
bufferView: 0,
|
||||
componentType: 5126,
|
||||
count: 3,
|
||||
type: "VEC3",
|
||||
sparse: {
|
||||
count: 1,
|
||||
indices: { bufferView: 4, componentType: 5121 },
|
||||
values: { bufferView: 5 },
|
||||
},
|
||||
},
|
||||
{
|
||||
bufferView: 1,
|
||||
componentType: 5123,
|
||||
normalized: true,
|
||||
count: 3,
|
||||
type: "VEC2",
|
||||
},
|
||||
{
|
||||
bufferView: 2,
|
||||
componentType: 5121,
|
||||
normalized: true,
|
||||
count: 3,
|
||||
type: "VEC3",
|
||||
},
|
||||
{
|
||||
bufferView: 3,
|
||||
componentType: 5123,
|
||||
count: 3,
|
||||
type: "SCALAR",
|
||||
},
|
||||
],
|
||||
images: [
|
||||
{
|
||||
uri: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
},
|
||||
],
|
||||
samplers: [
|
||||
{ magFilter: 9728, minFilter: 9984, wrapS: 33071, wrapT: 33648 },
|
||||
],
|
||||
textures: [{ source: 0, sampler: 0 }],
|
||||
materials: [
|
||||
{
|
||||
alphaMode: "BLEND",
|
||||
doubleSided: true,
|
||||
normalTexture: { ...texture, scale: 0.75 },
|
||||
emissiveTexture: texture,
|
||||
emissiveFactor: [0.4, 0.5, 0.6],
|
||||
occlusionTexture: { ...texture, strength: 0.7 },
|
||||
extensions: {
|
||||
KHR_materials_unlit: {},
|
||||
KHR_materials_pbrSpecularGlossiness: {
|
||||
diffuseFactor: [0.1, 0.2, 0.3, 0.4],
|
||||
specularFactor: [0.5, 0.6, 0.7],
|
||||
glossinessFactor: 0.75,
|
||||
diffuseTexture: {
|
||||
...texture,
|
||||
extensions: {
|
||||
KHR_texture_transform: {
|
||||
offset: [0.1, 0.2],
|
||||
scale: [0.3, 0.4],
|
||||
rotation: 0.5,
|
||||
texCoord: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
specularGlossinessTexture: texture,
|
||||
},
|
||||
KHR_materials_transmission: {
|
||||
transmissionFactor: 0.8,
|
||||
transmissionTexture: texture,
|
||||
},
|
||||
KHR_materials_volume: {
|
||||
thicknessFactor: 0.9,
|
||||
thicknessTexture: texture,
|
||||
attenuationDistance: 3,
|
||||
attenuationColor: [0.2, 0.3, 0.4],
|
||||
},
|
||||
KHR_materials_clearcoat: {
|
||||
clearcoatFactor: 0.45,
|
||||
clearcoatRoughnessFactor: 0.25,
|
||||
clearcoatTexture: texture,
|
||||
},
|
||||
KHR_materials_sheen: {
|
||||
sheenColorFactor: [0.3, 0.2, 0.1],
|
||||
sheenRoughnessFactor: 0.35,
|
||||
},
|
||||
KHR_materials_specular: {
|
||||
specularFactor: 0.55,
|
||||
specularColorFactor: [0.7, 0.8, 0.9],
|
||||
specularTexture: texture,
|
||||
specularColorTexture: texture,
|
||||
},
|
||||
KHR_materials_iridescence: {
|
||||
iridescenceFactor: 0.65,
|
||||
iridescenceIor: 1.4,
|
||||
iridescenceThicknessMinimum: 120,
|
||||
iridescenceThicknessMaximum: 360,
|
||||
iridescenceTexture: texture,
|
||||
iridescenceThicknessTexture: texture,
|
||||
},
|
||||
KHR_materials_anisotropy: {
|
||||
anisotropyStrength: 0.75,
|
||||
anisotropyRotation: 0.15,
|
||||
anisotropyTexture: texture,
|
||||
},
|
||||
KHR_materials_emissive_strength: { emissiveStrength: 4 },
|
||||
KHR_materials_ior: { ior: 1.6 },
|
||||
},
|
||||
},
|
||||
],
|
||||
meshes: [
|
||||
{
|
||||
primitives: [
|
||||
{
|
||||
attributes: {
|
||||
POSITION: 0,
|
||||
TEXCOORD_0: 1,
|
||||
TEXCOORD_1: 1,
|
||||
COLOR_0: 2,
|
||||
},
|
||||
indices: 3,
|
||||
material: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
cameras: [{ type: "perspective", perspective: { yfov: 1.1 } }],
|
||||
nodes: [
|
||||
{
|
||||
mesh: 0,
|
||||
camera: 0,
|
||||
translation: [5, 0, 0],
|
||||
scale: [-1, 1, 1],
|
||||
extensions: { KHR_lights_punctual: { light: 0 } },
|
||||
},
|
||||
],
|
||||
};
|
||||
const previousCreateImageBitmap = globalThis.createImageBitmap;
|
||||
Object.defineProperty(globalThis, "createImageBitmap", {
|
||||
configurable: true,
|
||||
value: async () => ({ width: 1, height: 1 }),
|
||||
});
|
||||
try {
|
||||
const result = await loadGltf(
|
||||
dataUrl("model/gltf+json", JSON.stringify(document)),
|
||||
);
|
||||
assert.equal(result.primitives.length, 1);
|
||||
const primitive = result.primitives[0];
|
||||
close(primitive.positions, [5, 0, 0, 3, 0, 0, 5, 1, 0]);
|
||||
assert.deepEqual(Array.from(primitive.indices), [0, 2, 1]);
|
||||
close(primitive.normals, [0, 0, 1, 0, 0, 1, 0, 0, 1]);
|
||||
close(primitive.uvs, [0, 0, 1, 0, 0, 1]);
|
||||
close(primitive.uvs2, [0, 0, 1, 0, 0, 1]);
|
||||
close(primitive.colors, [1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1]);
|
||||
assert.equal(primitive.tangents.length, 12);
|
||||
assert.equal(Array.from(primitive.tangents).every(Number.isFinite), true);
|
||||
|
||||
const material = result.materials[0];
|
||||
close(material.baseColor, [0.1, 0.2, 0.3, 0.4]);
|
||||
assert.equal(material.metallic, 0);
|
||||
assert.equal(material.roughness, 0.25);
|
||||
assert.equal(material.flags, 30);
|
||||
assert.equal(material.transmission, 0.8);
|
||||
assert.equal(material.ior, 1.6);
|
||||
assert.equal(material.thickness, 0.9);
|
||||
assert.equal(material.attenuationDistance, 3);
|
||||
close(material.attenuationColor, [0.2, 0.3, 0.4]);
|
||||
assert.equal(material.emissiveStrength, 4);
|
||||
assert.equal(material.clearcoat, 0.45);
|
||||
assert.equal(material.clearcoatRoughness, 0.25);
|
||||
assert.equal(material.specular, 0.55);
|
||||
close(material.specularColor, [0.5, 0.6, 0.7]);
|
||||
close(material.specularExtensionColor, [0.7, 0.8, 0.9]);
|
||||
assert.equal(material.iridescence, 0.65);
|
||||
assert.equal(material.anisotropy, 0.75);
|
||||
assert.deepEqual(
|
||||
[
|
||||
material.baseColorTexture,
|
||||
material.metallicRoughnessTexture,
|
||||
material.normalTexture,
|
||||
material.emissiveTexture,
|
||||
material.occlusionTexture,
|
||||
material.transmissionTexture,
|
||||
material.thicknessTexture,
|
||||
material.clearcoatTexture,
|
||||
material.iridescenceTexture,
|
||||
material.iridescenceThicknessTexture,
|
||||
material.anisotropyTexture,
|
||||
material.specularTexture,
|
||||
material.specularColorTexture,
|
||||
],
|
||||
Array(13).fill(0),
|
||||
);
|
||||
assert.deepEqual(material.textureInfos[0], {
|
||||
texture: 0,
|
||||
texCoord: 1,
|
||||
offset: [0.1, 0.2],
|
||||
scale: [0.3, 0.4],
|
||||
rotation: 0.5,
|
||||
});
|
||||
|
||||
assert.equal(result.textures.length, 1);
|
||||
assert.deepEqual(result.textures[0].sampler, {
|
||||
magFilter: "nearest",
|
||||
minFilter: "nearest",
|
||||
mipmapFilter: "nearest",
|
||||
addressModeU: "clamp-to-edge",
|
||||
addressModeV: "mirror-repeat",
|
||||
});
|
||||
assert.deepEqual(result.camera, { direction: [0, 0, 1], fov: 1.1 });
|
||||
assert.equal(result.lights.length, 1);
|
||||
const { rotor, ...light } = result.lights[0];
|
||||
assert.deepEqual(light, {
|
||||
type: "spot",
|
||||
color: [0.2, 0.4, 0.6],
|
||||
intensity: 7,
|
||||
range: 8,
|
||||
innerAngle: 0.1,
|
||||
outerAngle: 0.5,
|
||||
position: [5, 0, 0],
|
||||
});
|
||||
close(rotor, [0, 0, 0, 1]);
|
||||
} finally {
|
||||
if (previousCreateImageBitmap)
|
||||
Object.defineProperty(globalThis, "createImageBitmap", {
|
||||
configurable: true,
|
||||
value: previousCreateImageBitmap,
|
||||
});
|
||||
else delete (globalThis as any).createImageBitmap;
|
||||
}
|
||||
});
|
||||
|
||||
test("binary GLB parsing consumes JSON and BIN chunks", async () => {
|
||||
const binary = new Uint8Array(
|
||||
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer,
|
||||
);
|
||||
const document = {
|
||||
asset: { version: "2.0" },
|
||||
buffers: [{ byteLength: binary.length }],
|
||||
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: binary.length }],
|
||||
accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
|
||||
};
|
||||
|
||||
const result = await loadGltf(
|
||||
dataUrl("model/gltf-binary", glb(document, binary)),
|
||||
);
|
||||
assert.equal(result.primitives.length, 1);
|
||||
close(result.primitives[0].positions, [0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
||||
close(result.primitives[0].normals, [0, 0, 1, 0, 0, 1, 0, 0, 1]);
|
||||
assert.deepEqual(Array.from(result.primitives[0].indices), [0, 1, 2]);
|
||||
assert.deepEqual(result.materials, []);
|
||||
assert.deepEqual(result.textures, []);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const directory = fileURLToPath(new URL(".", import.meta.url));
|
||||
const mock = join(directory, "core-mock.ts");
|
||||
const output = await mkdtemp(join(tmpdir(), "yawn-handles-tests-"));
|
||||
|
||||
try {
|
||||
const entries = (await readdir(directory))
|
||||
.filter((name) => name.endsWith(".test.ts"))
|
||||
.sort()
|
||||
.map((name) => join(directory, name));
|
||||
await build({
|
||||
entryPoints: entries,
|
||||
outdir: output,
|
||||
entryNames: "[name]",
|
||||
outExtension: { ".js": ".mjs" },
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
sourcemap: "inline",
|
||||
plugins: [
|
||||
{
|
||||
name: "mock-yawn-core",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@yawn\/core$/ }, () => ({ path: mock }));
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const tests = entries.map((entry) =>
|
||||
join(output, `${basename(entry, ".ts")}.mjs`),
|
||||
);
|
||||
const result = spawnSync(process.execPath, ["--test", ...tests], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
} finally {
|
||||
await rm(output, { force: true, recursive: true });
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { Mesh } from "../src/Mesh";
|
||||
import { FXAA } from "../src/PostProcesses/FXAA";
|
||||
import { PBRMaterial } from "../src/materials/PBRMaterial";
|
||||
import { Texture } from "../src/materials/Texture";
|
||||
import { ShaderMaterial } from "../src/materials/ShaderMaterial";
|
||||
import { PointLight } from "../src/lights/PointLight";
|
||||
import { SpotLight } from "../src/lights/SpotLight";
|
||||
import { DirectionalLight } from "../src/lights/DirectionalLight";
|
||||
import { ComputePass } from "../src/ComputePass";
|
||||
import {
|
||||
bucketDraws,
|
||||
createScene,
|
||||
detailedMesh,
|
||||
forwardPasses,
|
||||
latestGraph,
|
||||
pbrPipelines,
|
||||
renderPipeline,
|
||||
testCore,
|
||||
triangle,
|
||||
} from "./scene-helpers";
|
||||
|
||||
if (typeof globalThis.ImageBitmap === "undefined")
|
||||
Object.defineProperty(globalThis, "ImageBitmap", {
|
||||
value: class ImageBitmap {},
|
||||
});
|
||||
|
||||
test("empty scenes use neutral authored defaults without placeholder geometry", async () => {
|
||||
const scene = await createScene();
|
||||
const emptyPass = latestGraph(scene).passes.find(
|
||||
(pass: any) => pass.id === "forward-empty",
|
||||
);
|
||||
|
||||
assert.equal(emptyPass.draw.vertices, 0);
|
||||
assert.deepEqual(emptyPass.color[0].clear, [0, 0, 0, 1]);
|
||||
assert.deepEqual(Array.from(scene.array("sceneAccent").row(0)), [1, 1, 1, 1]);
|
||||
});
|
||||
|
||||
test("opaque PBR graph has no implicit environment and compacts sparse lights", async () => {
|
||||
const scene = await createScene();
|
||||
await Promise.all([
|
||||
new PointLight(scene).ready,
|
||||
new SpotLight(scene).ready,
|
||||
new DirectionalLight(scene).ready,
|
||||
]);
|
||||
const material = new PBRMaterial(scene);
|
||||
await material.ready;
|
||||
const mesh = detailedMesh(scene, material);
|
||||
await mesh.ready;
|
||||
|
||||
const graph = latestGraph(scene);
|
||||
const pipeline = pbrPipelines(graph)[0];
|
||||
const forward = forwardPasses(graph)[0];
|
||||
const present = renderPipeline(graph, "present");
|
||||
const clusters = graph.pipelines.compute.find(
|
||||
(candidate: any) => candidate.id === "cluster-lights",
|
||||
);
|
||||
|
||||
assert.equal(pipeline.primitive.frontFace, "ccw");
|
||||
assert.equal(
|
||||
graph.passes.some((pass: any) => pass.id.includes("background")),
|
||||
false,
|
||||
);
|
||||
assert.doesNotMatch(JSON.stringify(graph), /studio/iu);
|
||||
assert.deepEqual(forward.color[0].clear, [0, 0, 0, 1]);
|
||||
assert.match(pipeline.code, /@interpolate\(flat\) material: u32/u);
|
||||
assert.match(pipeline.code, /materials\[input\.material \* 3u\]/u);
|
||||
assert.doesNotMatch(pipeline.code, /defaultLight|studioEnvironment/u);
|
||||
assert.doesNotMatch(pipeline.code, /0\.4, 0\.7, 0\.6/u);
|
||||
assert.match(
|
||||
pipeline.code,
|
||||
/let ambient = vec3<f32>\(bitcast<f32>\(clusters\[1012\]\)/u,
|
||||
);
|
||||
assert.match(pipeline.code, /fn directContribution/u);
|
||||
assert.match(pipeline.code, /fn directionalContribution/u);
|
||||
assert.equal(
|
||||
forward.bindings.find((binding: any) => binding.binding === 10)?.resource,
|
||||
"directional-lights",
|
||||
);
|
||||
|
||||
assert.match(clusters.code, /clusters\[11u \+ pointCount\] = i/u);
|
||||
assert.match(clusters.code, /clusters\[512u \+ spotCount\] = i/u);
|
||||
assert.match(pipeline.code, /let pointCount = clusters\[10\]/u);
|
||||
assert.match(pipeline.code, /pointCount <= 500u/u);
|
||||
assert.match(pipeline.code, /arrayLength\(&pointLights\) \/ 2u/u);
|
||||
assert.match(
|
||||
pipeline.code,
|
||||
/let sourceRoughness = clamp\(roughness \+ max\(state\.z/u,
|
||||
);
|
||||
assert.match(pipeline.code, /let spotCount = clusters\[511\]/u);
|
||||
assert.match(pipeline.code, /spotCount <= 500u/u);
|
||||
});
|
||||
|
||||
test("materials with identical shader features share one pipeline", async () => {
|
||||
const scene = await createScene();
|
||||
const firstMaterial = new PBRMaterial(scene, { roughness: 0.2 });
|
||||
const secondMaterial = new PBRMaterial(scene, { roughness: 0.8 });
|
||||
await Promise.all([firstMaterial.ready, secondMaterial.ready]);
|
||||
const firstMesh = detailedMesh(scene, firstMaterial);
|
||||
const secondMesh = detailedMesh(scene, secondMaterial);
|
||||
await Promise.all([firstMesh.ready, secondMesh.ready]);
|
||||
|
||||
const graph = latestGraph(scene);
|
||||
const draws = bucketDraws(scene, graph);
|
||||
assert.equal(pbrPipelines(graph).length, 1);
|
||||
// One indirect bucket pass holds both meshes: same pipeline and bind set.
|
||||
assert.equal(forwardPasses(graph).length, 1);
|
||||
assert.deepEqual(
|
||||
draws.map((draw) => draw.firstInstance & 0xffff),
|
||||
[firstMesh.id, secondMesh.id],
|
||||
);
|
||||
assert.deepEqual(
|
||||
draws.map((draw) => (draw.firstInstance >>> 16) - 1),
|
||||
[firstMaterial.id, secondMaterial.id],
|
||||
);
|
||||
});
|
||||
|
||||
test("transmission renders directly into a composited layer with five taps", async () => {
|
||||
const scene = await createScene();
|
||||
const material = new PBRMaterial(scene, {
|
||||
transmission: 0.9,
|
||||
thickness: 0.25,
|
||||
attenuationDistance: 2,
|
||||
});
|
||||
await material.ready;
|
||||
const mesh = detailedMesh(scene, material);
|
||||
await mesh.ready;
|
||||
|
||||
const graph = latestGraph(scene);
|
||||
const pipeline = pbrPipelines(graph)[0];
|
||||
const present = renderPipeline(graph, "present");
|
||||
const presentPass = graph.passes.find((pass: any) => pass.id === "present");
|
||||
|
||||
assert.equal(
|
||||
graph.passes.some((pass: any) => pass.id === "scene-color-clear"),
|
||||
true,
|
||||
);
|
||||
const sceneColorClear = graph.passes.find(
|
||||
(pass: any) => pass.id === "scene-color-clear",
|
||||
);
|
||||
assert.equal(sceneColorClear.draw.vertices, 0);
|
||||
assert.deepEqual(sceneColorClear.color[0].clear, [0, 0, 0, 1]);
|
||||
assert.equal(
|
||||
graph.passes.some((pass: any) => pass.id === "copy-scene-color"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
presentPass.bindings.some(
|
||||
(binding: any) => binding.resource === "scene-color",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.match(present.code, /textureSample\(scene, sourceSampler/u);
|
||||
assert.equal(
|
||||
pipeline.code.match(/textureSampleLevel\(sceneColor/g)?.length,
|
||||
5,
|
||||
);
|
||||
assert.match(pipeline.code, /transmitted \*= pow/u);
|
||||
});
|
||||
|
||||
test("clear color is explicit scene data used by empty and forward graphs", async () => {
|
||||
const clearColor = [0.03, 0.06, 0.09, 1] as const;
|
||||
const scene = await createScene({ clearColor });
|
||||
const empty = latestGraph(scene).passes.find(
|
||||
(pass: any) => pass.id === "forward-empty",
|
||||
);
|
||||
assert.deepEqual(empty.color[0].clear, clearColor);
|
||||
|
||||
const material = new PBRMaterial(scene);
|
||||
await material.ready;
|
||||
const mesh = detailedMesh(scene, material);
|
||||
await mesh.ready;
|
||||
|
||||
const graph = latestGraph(scene);
|
||||
assert.deepEqual(forwardPasses(graph)[0].color[0].clear, clearColor);
|
||||
assert.deepEqual(
|
||||
graph.passes.find((pass: any) => pass.id === "present").color[0].clear,
|
||||
clearColor,
|
||||
);
|
||||
});
|
||||
|
||||
test("post-processing keeps the conservative transmission copy", async () => {
|
||||
const scene = await createScene();
|
||||
const material = new PBRMaterial(scene, { transmission: 1 });
|
||||
await material.ready;
|
||||
const mesh = detailedMesh(scene, material);
|
||||
await mesh.ready;
|
||||
const effect = new FXAA(scene);
|
||||
await effect.ready;
|
||||
|
||||
const graph = latestGraph(scene);
|
||||
const presentPass = graph.passes.find((pass: any) => pass.id === "present");
|
||||
assert.equal(
|
||||
graph.passes.some((pass: any) => pass.id === "copy-scene-color"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
presentPass.bindings.some(
|
||||
(binding: any) => binding.resource === "scene-color",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
graph.passes.some((pass: any) => pass.pipeline.startsWith("post-fxaa-")),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("PBR generation eliminates inactive features and specializes active ones", async () => {
|
||||
const scene = await createScene();
|
||||
const textures = Array.from(
|
||||
{ length: 13 },
|
||||
() => new Texture(scene, { size: [1, 1, 1] }),
|
||||
);
|
||||
await Promise.all(textures.map((texture) => texture.ready));
|
||||
const textureOptions = {
|
||||
baseColorTexture: textures[0],
|
||||
metallicRoughnessTexture: textures[1],
|
||||
normalTexture: textures[2],
|
||||
emissiveTexture: textures[3],
|
||||
occlusionTexture: textures[4],
|
||||
transmissionTexture: textures[5],
|
||||
thicknessTexture: textures[6],
|
||||
clearcoatTexture: textures[7],
|
||||
iridescenceTexture: textures[8],
|
||||
iridescenceThicknessTexture: textures[9],
|
||||
anisotropyTexture: textures[10],
|
||||
specularTexture: textures[11],
|
||||
specularColorTexture: textures[12],
|
||||
};
|
||||
const active = new PBRMaterial(scene, {
|
||||
...textureOptions,
|
||||
transmission: 1,
|
||||
thickness: 0.5,
|
||||
attenuationDistance: 2,
|
||||
clearcoat: 0.7,
|
||||
sheenColor: [0.2, 0.3, 0.4],
|
||||
iridescence: 0.8,
|
||||
anisotropy: 0.6,
|
||||
textureInfos: Array.from({ length: 13 }, (_, slot) => ({
|
||||
texCoord: slot === 1 ? 1 : 0,
|
||||
})),
|
||||
});
|
||||
const unlit = new PBRMaterial(scene, { ...textureOptions, flags: 8 });
|
||||
await Promise.all([active.ready, unlit.ready]);
|
||||
const activeMesh = detailedMesh(scene, active);
|
||||
const unlitMesh = detailedMesh(scene, unlit);
|
||||
await Promise.all([activeMesh.ready, unlitMesh.ready]);
|
||||
|
||||
const pipelines = pbrPipelines(latestGraph(scene));
|
||||
const activeCode = pipelines.find((pipeline: any) =>
|
||||
pipeline.code.includes("transmissionAmount"),
|
||||
).code;
|
||||
const unlitCode = pipelines.find((pipeline: any) =>
|
||||
pipeline.code.includes("color = base.rgb + emissive"),
|
||||
).code;
|
||||
|
||||
for (const binding of [
|
||||
"baseTexture",
|
||||
"materialTexture",
|
||||
"normalTexture",
|
||||
"emissiveTexture",
|
||||
"occlusionTexture",
|
||||
"transmissionTexture",
|
||||
"thicknessTexture",
|
||||
"clearcoatTexture",
|
||||
"iridescenceTexture",
|
||||
"iridescenceThicknessTexture",
|
||||
"anisotropyTexture",
|
||||
"specularTexture",
|
||||
"specularColorTexture",
|
||||
])
|
||||
assert.match(activeCode, new RegExp(`var ${binding}: texture_2d`));
|
||||
assert.match(
|
||||
activeCode,
|
||||
/textureSample\(baseTexture, materialSampler, input\.uv\)/u,
|
||||
);
|
||||
assert.match(
|
||||
activeCode,
|
||||
/textureSample\(materialTexture, materialSampler, input\.uv2\)/u,
|
||||
);
|
||||
assert.match(activeCode, /let anisotropyAmount/u);
|
||||
assert.match(activeCode, /let iridescenceAmount/u);
|
||||
assert.match(activeCode, /let clearcoatFresnel/u);
|
||||
assert.match(activeCode, /let sheen =/u);
|
||||
assert.match(unlitCode, /var baseTexture: texture_2d/u);
|
||||
assert.match(unlitCode, /var emissiveTexture: texture_2d/u);
|
||||
assert.doesNotMatch(unlitCode, /var normalTexture: texture_2d/u);
|
||||
assert.doesNotMatch(unlitCode, /var clearcoatTexture: texture_2d/u);
|
||||
assert.doesNotMatch(unlitCode, /let clearcoatFresnel/u);
|
||||
});
|
||||
|
||||
test("alpha blending uses source-over and disables depth writes", async () => {
|
||||
const scene = await createScene();
|
||||
const material = new PBRMaterial(scene, { flags: 2 });
|
||||
await material.ready;
|
||||
const mesh = detailedMesh(scene, material);
|
||||
await mesh.ready;
|
||||
|
||||
const pipeline = pbrPipelines(latestGraph(scene))[0];
|
||||
assert.deepEqual(pipeline.fragment.targets[0].blend, {
|
||||
color: {
|
||||
srcFactor: "src-alpha",
|
||||
dstFactor: "one-minus-src-alpha",
|
||||
operation: "add",
|
||||
},
|
||||
alpha: {
|
||||
srcFactor: "one",
|
||||
dstFactor: "one-minus-src-alpha",
|
||||
operation: "add",
|
||||
},
|
||||
});
|
||||
assert.equal(pipeline.depthStencil.depth_write_enabled, false);
|
||||
});
|
||||
|
||||
test("batchGraphUpdates compiles once for multiple mesh registrations", async () => {
|
||||
const scene = await createScene();
|
||||
const material = new PBRMaterial(scene);
|
||||
await material.ready;
|
||||
const before = testCore(scene).graphs.length;
|
||||
|
||||
await scene.batchGraphUpdates(async () => {
|
||||
const first = new Mesh(scene, { material, vertexData: triangle });
|
||||
const second = new Mesh(scene, { material, vertexData: triangle });
|
||||
await Promise.all([first.ready, second.ready]);
|
||||
});
|
||||
|
||||
assert.equal(testCore(scene).graphs.length - before, 1);
|
||||
assert.equal(forwardPasses(latestGraph(scene)).length, 1);
|
||||
assert.equal(bucketDraws(scene, latestGraph(scene)).length, 2);
|
||||
});
|
||||
|
||||
test("draw buckets persist per bind set, zero their inactive tail, and stay blocked while compiling", async () => {
|
||||
const scene = await createScene();
|
||||
const core = testCore(scene);
|
||||
const plain = new PBRMaterial(scene);
|
||||
const texture = new Texture(scene, { size: [1, 1, 1] });
|
||||
await texture.ready;
|
||||
const textured = new PBRMaterial(scene, { baseColorTexture: texture });
|
||||
await Promise.all([plain.ready, textured.ready]);
|
||||
const meshes: Mesh[] = [];
|
||||
for (let index = 0; index < 3; index++)
|
||||
meshes.push(await detailedMesh(scene, plain).ready);
|
||||
|
||||
// Three plain meshes fill one bucket; the array grew geometrically from 1 → 2 → 4 rows.
|
||||
let graph = latestGraph(scene);
|
||||
const bucket = graph.resources.buffers.find((buffer: any) =>
|
||||
buffer.id.startsWith("draws-"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ usage: bucket.usage, sync: bucket.sync },
|
||||
{ usage: ["indirect"], sync: "manual" },
|
||||
);
|
||||
assert.equal(core.array(bucket.array).rows, 4);
|
||||
assert.equal(bucketDraws(scene, graph).length, 3);
|
||||
assert.equal(forwardPasses(graph).length, 1);
|
||||
|
||||
// Moving one mesh to a textured material creates a second bucket; the first keeps two rows
|
||||
// and its freed tail is zero (instanceCount 0) so the GPU skips it.
|
||||
meshes[1].material = textured;
|
||||
await meshes[1].ready;
|
||||
await scene.updateRenderGraph();
|
||||
graph = latestGraph(scene);
|
||||
assert.equal(forwardPasses(graph).length, 2);
|
||||
const draws = bucketDraws(scene, graph);
|
||||
assert.deepEqual(
|
||||
draws.map((draw) => [draw.pass, draw.firstInstance & 0xffff]),
|
||||
[
|
||||
["forward-bucket-0", meshes[0].id],
|
||||
["forward-bucket-0", meshes[2].id],
|
||||
["forward-bucket-1", meshes[1].id],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...core.array(bucket.array).view.subarray(16, 32)],
|
||||
Array(16).fill(0),
|
||||
);
|
||||
|
||||
// Back to plain: the second bucket vanishes from the graph, the first regains its row.
|
||||
meshes[1].material = plain;
|
||||
await meshes[1].ready;
|
||||
await scene.updateRenderGraph();
|
||||
graph = latestGraph(scene);
|
||||
assert.equal(forwardPasses(graph).length, 1);
|
||||
assert.equal(bucketDraws(scene, graph).length, 3);
|
||||
|
||||
// A compile that starts after an earlier switch cleared signal 6 re-asserts it before
|
||||
// touching bucket rows, so no frame renders the old graph with new bucket contents.
|
||||
const signals = core.array("signals").row(0);
|
||||
const switchLoadout = core.switchLoadout.bind(core);
|
||||
let observed = -1;
|
||||
core.switchLoadout = async (id) => {
|
||||
await switchLoadout(id);
|
||||
signals[6] = 0;
|
||||
core.switchLoadout = async (next) => {
|
||||
observed = signals[6];
|
||||
await switchLoadout(next);
|
||||
};
|
||||
};
|
||||
await Promise.all([scene.updateRenderGraph(), scene.updateRenderGraph()]);
|
||||
assert.equal(observed, 1);
|
||||
core.switchLoadout = switchLoadout;
|
||||
});
|
||||
|
||||
test("opaque buckets render into the composited layer ahead of transmission", async () => {
|
||||
const scene = await createScene();
|
||||
const opaque = new PBRMaterial(scene);
|
||||
const glass = new PBRMaterial(scene, { transmission: 0.9 });
|
||||
await Promise.all([opaque.ready, glass.ready]);
|
||||
const solid = detailedMesh(scene, opaque);
|
||||
const window = detailedMesh(scene, glass);
|
||||
await Promise.all([solid.ready, window.ready]);
|
||||
|
||||
const passes = forwardPasses(latestGraph(scene));
|
||||
assert.deepEqual(
|
||||
passes.map((pass: any) => [
|
||||
pass.id,
|
||||
pass.color[0].resource,
|
||||
Boolean(pass.indirect),
|
||||
]),
|
||||
[
|
||||
["forward-bucket-0", "scene-color", true],
|
||||
[
|
||||
`forward-${window.id}-${scene.geometry(window.geometryId).indexRow * 4}`,
|
||||
"hdr",
|
||||
false,
|
||||
],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("render graph snapshots expose isolated scene-owned declarations", async () => {
|
||||
const scene = await createScene();
|
||||
const source: any = await scene.getRenderGraph();
|
||||
|
||||
assert.equal(source.id, "scene");
|
||||
assert.ok(
|
||||
source.resources.buffers.some((buffer: any) => buffer.id === "clusters"),
|
||||
);
|
||||
source.id = "mutated";
|
||||
source.resources.buffers.length = 0;
|
||||
|
||||
const next: any = await scene.getRenderGraph();
|
||||
assert.equal(next.id, "scene");
|
||||
assert.ok(
|
||||
next.resources.buffers.some((buffer: any) => buffer.id === "clusters"),
|
||||
);
|
||||
});
|
||||
|
||||
test("serialized render graphs compile into explicitly selectable loadouts", async () => {
|
||||
const scene = await createScene();
|
||||
const core = testCore(scene);
|
||||
const serialized = core.serializedGraphs.at(-1)!;
|
||||
const before = core.graphs.length;
|
||||
|
||||
const loadoutId = await scene.compileRenderGraph(serialized);
|
||||
await scene.setLoadout(loadoutId);
|
||||
|
||||
assert.equal(core.graphs.length, before + 1);
|
||||
assert.equal(loadoutId, `loadout-${before + 1}`);
|
||||
assert.equal(core.loadouts.at(-1), loadoutId);
|
||||
});
|
||||
|
||||
test("packed ORM reads are shared only for the same texture and UV transform", async () => {
|
||||
const scene = await createScene();
|
||||
const orm = new Texture(scene, { size: [1, 1, 1] });
|
||||
const other = new Texture(scene, { size: [1, 1, 1] });
|
||||
await Promise.all([orm.ready, other.ready]);
|
||||
for (const [occlusion, info, shared] of [
|
||||
[orm, {}, true],
|
||||
[other, {}, false],
|
||||
[orm, { texCoord: 1 }, false],
|
||||
[orm, { offset: [0.25, -0.1] }, false],
|
||||
[orm, { scale: [2, 1] }, false],
|
||||
[orm, { rotation: 0.4 }, false],
|
||||
] as const) {
|
||||
const material = new PBRMaterial(scene, {
|
||||
metallicRoughnessTexture: orm,
|
||||
occlusionTexture: occlusion,
|
||||
textureInfos: [{}, {}, {}, {}, info],
|
||||
});
|
||||
await material.ready;
|
||||
const mesh = await detailedMesh(scene, material).ready;
|
||||
const graph = latestGraph(scene);
|
||||
const pass = forwardPasses(graph).at(-1);
|
||||
const code = renderPipeline(graph, pass.pipeline).code;
|
||||
assert.equal(code.includes("mix(1.0, packedMaterial.r"), shared);
|
||||
assert.equal(code.includes("textureSample(occlusionTexture"), !shared);
|
||||
assert.equal(
|
||||
pass.bindings.some((b: any) => b.group === 1 && b.binding === 5),
|
||||
!shared,
|
||||
);
|
||||
await mesh.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("light variants follow first/last light lifetimes and keep hot edits rebuild-free", async () => {
|
||||
const scene = await createScene();
|
||||
const material = await new PBRMaterial(scene).ready;
|
||||
await detailedMesh(scene, material).ready;
|
||||
const code = () => pbrPipelines(latestGraph(scene))[0].code;
|
||||
assert.doesNotMatch(code(), /fn (point|spot|directional)Contribution/);
|
||||
const first = await new PointLight(scene, { intensity: 0 }).ready;
|
||||
assert.match(code(), /fn pointContribution/);
|
||||
assert.doesNotMatch(code(), /fn (spot|directional)Contribution/);
|
||||
assert.equal(code().match(/let nDotV =/g)?.length, 1);
|
||||
assert.match(code(), /let diffuseColor = base.rgb \* \(1.0 - metallic\)/);
|
||||
const before = testCore(scene).graphs.length;
|
||||
first.intensity = 3;
|
||||
first.radius = 0.4;
|
||||
const second = await new PointLight(scene).ready;
|
||||
await first.dispose();
|
||||
assert.equal(testCore(scene).graphs.length, before);
|
||||
assert.match(code(), /fn pointContribution/);
|
||||
const spot = await new SpotLight(scene).ready;
|
||||
const directional = await new DirectionalLight(scene).ready;
|
||||
assert.match(code(), /fn spotContribution/);
|
||||
assert.match(code(), /fn directionalContribution/);
|
||||
await second.dispose();
|
||||
assert.doesNotMatch(code(), /fn pointContribution/);
|
||||
await spot.dispose();
|
||||
await directional.dispose();
|
||||
assert.doesNotMatch(code(), /fn (point|spot|directional)Contribution/);
|
||||
});
|
||||
|
||||
test("direct canvas loadout preserves clear/depth and falls back for linear-color consumers", async () => {
|
||||
const scene = await createScene({
|
||||
directToCanvas: true,
|
||||
clearColor: [0.02, 0.09, 0.3, 0.5],
|
||||
});
|
||||
const material = await new PBRMaterial(scene).ready;
|
||||
const mesh = await detailedMesh(scene, material).ready;
|
||||
const assertDirect = () => {
|
||||
const graph = latestGraph(scene);
|
||||
assert.equal(
|
||||
graph.resources.textures.some((t: any) => t.id === "hdr"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
graph.passes.some((p: any) => p.id === "present"),
|
||||
false,
|
||||
);
|
||||
const background = graph.passes.find(
|
||||
(p: any) => p.id === "canvas-background",
|
||||
);
|
||||
assert.deepEqual(background.depth, { resource: "depth", clear: 1 });
|
||||
assert.match(
|
||||
renderPipeline(graph, "canvas-background").code,
|
||||
/canvasOutput\(vec4<f32>\(0.02, 0.09, 0.3, 0.5\)\)/,
|
||||
);
|
||||
const forward = forwardPasses(graph)[0];
|
||||
assert.deepEqual(forward.color, [{ resource: "canvas", load: "load" }]);
|
||||
assert.deepEqual(forward.depth, { resource: "depth", load: "load" });
|
||||
assert.equal(pbrPipelines(graph)[0].fragment.targets[0].format, "canvas");
|
||||
assert.match(pbrPipelines(graph)[0].code, /unpack2x16float\(pack2x16float/);
|
||||
};
|
||||
const assertHdr = () =>
|
||||
assert.ok(renderPipeline(latestGraph(scene), "present"));
|
||||
assertDirect();
|
||||
const effect = new FXAA(scene);
|
||||
await effect.ready;
|
||||
assertHdr();
|
||||
await effect.dispose();
|
||||
assertDirect();
|
||||
for (const options of [{ flags: 2 }, { transmission: 0.5 }]) {
|
||||
const special = await new PBRMaterial(scene, options).ready;
|
||||
const extra = await detailedMesh(scene, special).ready;
|
||||
assertHdr();
|
||||
await extra.dispose();
|
||||
assertDirect();
|
||||
}
|
||||
const compute = new ComputePass({
|
||||
code: "@compute @workgroup_size(1) fn main() {}",
|
||||
});
|
||||
await scene.addComputePass(compute);
|
||||
assertHdr();
|
||||
assert.match(
|
||||
pbrPipelines(latestGraph(scene))[0].code,
|
||||
/fn directionalContribution/,
|
||||
);
|
||||
await scene.removeComputePass(compute.id);
|
||||
assertDirect();
|
||||
const custom = new ShaderMaterial(scene, { code: "custom WGSL" });
|
||||
await custom.ready;
|
||||
assertHdr();
|
||||
await custom.dispose();
|
||||
assertDirect();
|
||||
const basic = await new Mesh(scene, {
|
||||
vertexData: { positions: triangle.positions, indices: triangle.indices },
|
||||
}).ready;
|
||||
assertHdr();
|
||||
await basic.dispose();
|
||||
assertDirect();
|
||||
const mask = await new PBRMaterial(scene, { flags: 1 }).ready;
|
||||
const masked = await detailedMesh(scene, mask).ready;
|
||||
assertDirect();
|
||||
assert.match(
|
||||
pbrPipelines(latestGraph(scene)).find((p: any) =>
|
||||
p.code.includes("discard"),
|
||||
).code,
|
||||
/canvasOutput/,
|
||||
);
|
||||
await masked.dispose();
|
||||
await mesh.dispose();
|
||||
assertHdr();
|
||||
const ldr = await createScene({ directToCanvas: true, hdr: false });
|
||||
const ldrMaterial = await new PBRMaterial(ldr).ready;
|
||||
await detailedMesh(ldr, ldrMaterial).ready;
|
||||
assert.ok(renderPipeline(latestGraph(ldr), "present"));
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Mesh } from "../src/Mesh";
|
||||
import { Scene } from "../src/Scene";
|
||||
import type { PBRMaterial } from "../src/materials/PBRMaterial";
|
||||
import type { YawnCore } from "./core-mock";
|
||||
|
||||
export const triangle = {
|
||||
positions: [-1, -1, 0, 1, -1, 0, 0, 1, 0],
|
||||
normals: [0, 0, 1, 0, 0, 1, 0, 0, 1],
|
||||
tangents: [1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1],
|
||||
uvs: [0, 0, 1, 0, 0.5, 1],
|
||||
uvs2: [0.1, 0.1, 0.9, 0.1, 0.5, 0.9],
|
||||
colors: [1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1],
|
||||
indices: [0, 1, 2],
|
||||
};
|
||||
|
||||
export async function createScene(
|
||||
options: ConstructorParameters<typeof Scene>[1] = {},
|
||||
) {
|
||||
const scene = new Scene({} as HTMLCanvasElement, options);
|
||||
await scene.ready;
|
||||
return scene;
|
||||
}
|
||||
|
||||
export function testCore(scene: Scene) {
|
||||
return scene.core as unknown as YawnCore;
|
||||
}
|
||||
|
||||
export function latestGraph(scene: Scene): any {
|
||||
return testCore(scene).graphs.at(-1);
|
||||
}
|
||||
|
||||
export function renderPipeline(graph: any, id: string) {
|
||||
return graph.pipelines.render.find((pipeline: any) => pipeline.id === id);
|
||||
}
|
||||
|
||||
export function pbrPipelines(graph: any) {
|
||||
return graph.pipelines.render.filter((pipeline: any) =>
|
||||
pipeline.id.startsWith("forward-pbr-"),
|
||||
);
|
||||
}
|
||||
|
||||
export function forwardPasses(graph: any) {
|
||||
return graph.passes.filter(
|
||||
(pass: any) =>
|
||||
pass.type === "render" && pass.pipeline.startsWith("forward-"),
|
||||
);
|
||||
}
|
||||
|
||||
export function detailedMesh(
|
||||
scene: Scene,
|
||||
material: PBRMaterial,
|
||||
vertexData: Record<string, ArrayLike<number>> = triangle,
|
||||
) {
|
||||
return new Mesh(scene, { material, vertexData });
|
||||
}
|
||||
|
||||
/** Decodes the active indirect rows of every forward bucket pass, in pass order. */
|
||||
export function bucketDraws(scene: Scene, graph: any) {
|
||||
return forwardPasses(graph)
|
||||
.filter((pass: any) => pass.indirect)
|
||||
.flatMap((pass: any) => {
|
||||
const buffer = graph.resources.buffers.find(
|
||||
(buffer: any) => buffer.id === pass.indirect.resource,
|
||||
);
|
||||
const view = scene.core.array(buffer.array).view;
|
||||
const draws = [];
|
||||
for (let row = 0; row * 8 < view.length; row++) {
|
||||
const [indices, instances, firstIndex, baseVertex, firstInstance] =
|
||||
view.subarray(row * 8, row * 8 + 5);
|
||||
if (instances)
|
||||
draws.push({
|
||||
pass: pass.id,
|
||||
indices,
|
||||
firstIndex,
|
||||
baseVertex,
|
||||
firstInstance,
|
||||
});
|
||||
}
|
||||
return draws;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# glTF screenshot tests
|
||||
|
||||
`npm run test:handles:screenshots` renders the viewer's empty state and every
|
||||
glTF-Binary model in the pinned Khronos glTF Sample Models revision. The suite
|
||||
uses headed Chromium on a private Xvfb display and SwiftShader WebGPU at a fixed
|
||||
1000×720 viewport. It checks the viewer state, browser diagnostics, and a
|
||||
250×180 downsample against each reviewed reference image.
|
||||
|
||||
The first run downloads roughly 215 MB of GLBs into
|
||||
`~/.cache/yawn/gltf-sample-models`. Set `YAWN_GLTF_FIXTURES` to use another
|
||||
cache directory, or `YAWN_SCREENSHOT_MODELS=Box,BoomBox` to run a subset.
|
||||
`YAWN_SCREENSHOT_BASELINES` points at another reference directory (for example
|
||||
one captured from a known-good commit on a machine whose software renderer does
|
||||
not match the reviewed references), and `YAWN_SCREENSHOT_SETTLE_MS` (default
|
||||
1200) is how long the viewer gets to settle after the model loads before the
|
||||
capture; raise it on slow software renderers.
|
||||
Failed full-size captures and visual diffs are written to `output/`.
|
||||
|
||||
To intentionally replace references after reviewing full-size renders, run:
|
||||
|
||||
```sh
|
||||
UPDATE_SCREENSHOTS=1 npm run test:handles:screenshots
|
||||
```
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 30 KiB |