Compare commits

..
Author SHA1 Message Date
Akash Shakdwipeea f1fd4df540 WIP: cross-platform 2026-08-24 00:27:03 +05:30
140 changed files with 911 additions and 11735 deletions
-5
View File
@@ -18,12 +18,7 @@ if ! command -v git-lfs >/dev/null; then
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y git-lfs sudo apt-get install -y git-lfs
fi 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 install --local
git lfs pull --include="docs/public/models/sponza.glb" git lfs pull --include="docs/public/models/sponza.glb"
npm ci npm ci
npx agent-browser install
amp orb services ensure amp orb services ensure
-1
View File
@@ -4,4 +4,3 @@ services:
portal: portal:
title: Yawn title: Yawn
description: Yawn website, tutorial, package server, and WebGPU playground. description: Yawn website, tutorial, package server, and WebGPU playground.
review: false
+1
View File
@@ -10,5 +10,6 @@ core/pkg
server/data server/data
docs docs
coredocs coredocs
vendor
*.log *.log
*.local *.local
-1
View File
@@ -32,7 +32,6 @@ docs/.vitepress/cache/
docs/.vitepress/dist/ docs/.vitepress/dist/
server/data/ server/data/
yawn-data/ yawn-data/
addons/handles/test/screenshots/output/
# Amp runtime artifacts # Amp runtime artifacts
.amp/in/ .amp/in/
Generated
+23
View File
@@ -117,12 +117,32 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 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]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.34" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.34" version = "0.3.34"
@@ -136,6 +156,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-macro",
"futures-task", "futures-task",
"pin-project-lite", "pin-project-lite",
"slab", "slab",
@@ -717,6 +738,8 @@ dependencies = [
name = "yawn-core" name = "yawn-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"futures-channel",
"futures-util",
"js-sys", "js-sys",
"serde", "serde",
"serde_json", "serde_json",
+3 -8
View File
@@ -18,11 +18,11 @@ RUN toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" \
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
COPY core/package.json ./core/package.json COPY core/package.json ./core/package.json
COPY addons ./addons COPY addons/handles/package.json ./addons/handles/package.json
COPY vendor ./vendor
RUN --mount=type=cache,target=/root/.npm npm ci --include=dev RUN --mount=type=cache,target=/root/.npm npm ci --include=dev
COPY core ./core COPY core ./core
COPY addons/handles ./addons/handles
COPY server/build.mjs ./server/build.mjs COPY server/build.mjs ./server/build.mjs
RUN --mount=type=cache,target=/src/target mkdir -p /out \ RUN --mount=type=cache,target=/src/target mkdir -p /out \
&& wasm-pack build core --target web --out-dir /out/pkg --release \ && wasm-pack build core --target web --out-dir /out/pkg --release \
@@ -42,9 +42,6 @@ RUN --mount=type=cache,target=/go/pkg/mod \
FROM debian:bookworm-slim AS runtime FROM debian:bookworm-slim AS runtime
ARG YAWN_DEPLOYED_AT=""
ARG YAWN_DEPLOY_COMMIT_MESSAGE=""
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \ && apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
@@ -61,9 +58,7 @@ COPY --from=package-builder --chown=yawn:yawn /src/node_modules/monaco-editor/mi
ENV PORT=5002 \ ENV PORT=5002 \
YAWN_ROOT=/app \ YAWN_ROOT=/app \
YAWN_PACKAGES=/app/packages \ 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 USER yawn:yawn
VOLUME ["/data"] VOLUME ["/data"]
-13
View File
@@ -1,13 +0,0 @@
{
"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"
}
}
File diff suppressed because it is too large Load Diff
-702
View File
@@ -1,702 +0,0 @@
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));
}
-352
View File
@@ -1,352 +0,0 @@
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;
}
}
-4
View File
@@ -1,4 +0,0 @@
export * from "./catalog";
export * from "./compiler";
export * from "./editor";
export * from "./physical";
-688
View File
@@ -1,688 +0,0 @@
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,
};
}
-415
View File
@@ -1,415 +0,0 @@
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,
);
});
-37
View File
@@ -1,37 +0,0 @@
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
View File
@@ -4,10 +4,6 @@
"description": "Conventional scene handles over Yawn's worker and shared render data", "description": "Conventional scene handles over Yawn's worker and shared render data",
"type": "module", "type": "module",
"exports": "./src/index.ts", "exports": "./src/index.ts",
"scripts": {
"test": "node test/run.mjs",
"test:screenshots": "node --test --test-concurrency=1 test/screenshots/run.mjs"
},
"dependencies": { "dependencies": {
"@yawn/core": "0.1.0" "@yawn/core": "0.1.0"
} }
+12 -50
View File
@@ -2,15 +2,7 @@ import { Node, type NodeOptions } from "./Node";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type { PBRMaterial } from "./materials/PBRMaterial"; import type { PBRMaterial } from "./materials/PBRMaterial";
export const VertexKinds = Object.freeze([ export const VertexKinds = Object.freeze(["positions", "normals", "tangents", "uvs", "colors", "indices"] as const);
"positions",
"normals",
"tangents",
"uvs",
"uvs2",
"colors",
"indices",
] as const);
export type VertexKind = (typeof VertexKinds)[number]; export type VertexKind = (typeof VertexKinds)[number];
export type MeshOptions = NodeOptions & { export type MeshOptions = NodeOptions & {
geometryId?: number; geometryId?: number;
@@ -35,17 +27,10 @@ export class Mesh extends Node {
this.ready = nodeReady.then(async () => { this.ready = nodeReady.then(async () => {
if (options.material) await options.material.ready; if (options.material) await options.material.ready;
await scene.ensureRows(`mesh.${this.id}.faceMaterials`, 1, 16, "u32"); await scene.ensureRows(`mesh.${this.id}.faceMaterials`, 1, 16, "u32");
this.vertexCount = this.vertexCount = Number(scene.geometryData(this.geometryId, "positions")?.length ?? 0) / 3;
Number(scene.geometryData(this.geometryId, "positions")?.length ?? 0) / this.indexCount = Number(scene.geometryData(this.geometryId, "indices")?.length ?? 0);
3; this.instanceOf = options.geometryId === undefined ? this.id : this.geometryId;
this.indexCount = Number( scene.array("meshInfo").write(this.id, [
scene.geometryData(this.geometryId, "indices")?.length ?? 0,
);
this.instanceOf =
options.geometryId === undefined ? this.id : this.geometryId;
scene
.array("meshInfo")
.write(this.id, [
this.geometryId, this.geometryId,
options.material?.id ?? 0, options.material?.id ?? 0,
options.visible === false ? 0 : 1, options.visible === false ? 0 : 1,
@@ -75,8 +60,7 @@ export class Mesh extends Node {
} }
set material(value: PBRMaterial) { set material(value: PBRMaterial) {
if (value.scene !== this.scene || value.id < 0) if (value.scene !== this.scene || value.id < 0) throw new Error("Await a material from the same Scene");
throw new Error("Await a material from the same Scene");
this.scene.markDirty(true); this.scene.markDirty(true);
this.scene.array("meshInfo").row(this.id)[1] = value.id; this.scene.array("meshInfo").row(this.id)[1] = value.id;
void this.scene.updateRenderGraph().catch(() => undefined); void this.scene.updateRenderGraph().catch(() => undefined);
@@ -88,18 +72,13 @@ export class Mesh extends Node {
} }
async setVertexData(kind: VertexKind, data: ArrayLike<number>) { async setVertexData(kind: VertexKind, data: ArrayLike<number>) {
if (!VertexKinds.includes(kind)) if (!VertexKinds.includes(kind)) throw new TypeError(`Unknown vertex kind: ${kind}`);
throw new TypeError(`Unknown vertex kind: ${kind}`);
await this.ready; await this.ready;
await this.#setVertexData(kind, data, true); await this.#setVertexData(kind, data, true);
return this; return this;
} }
async #setVertexData( async #setVertexData(kind: VertexKind, data: ArrayLike<number>, makeUnique: boolean) {
kind: VertexKind,
data: ArrayLike<number>,
makeUnique: boolean,
) {
if (makeUnique && this.scene.geometryReferences(this.geometryId) > 1) { if (makeUnique && this.scene.geometryReferences(this.geometryId) > 1) {
const original = this.geometryId; const original = this.geometryId;
this.geometryId = await this.scene.cloneGeometry(original); this.geometryId = await this.scene.cloneGeometry(original);
@@ -107,15 +86,7 @@ export class Mesh extends Node {
this.scene.referenceGeometry(this.geometryId); this.scene.referenceGeometry(this.geometryId);
this.instanceOf = this.id; this.instanceOf = this.id;
this.scene.markDirty(true); this.scene.markDirty(true);
this.scene this.scene.array("meshInfo").row(this.id).set([this.geometryId, this.materialId, this.isVisible ? 1 : 0, this.id]);
.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 === "positions") this.vertexCount = data.length / 3;
if (kind === "indices") this.indexCount = data.length; if (kind === "indices") this.indexCount = data.length;
@@ -125,16 +96,10 @@ export class Mesh extends Node {
async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) { async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) {
await Promise.all([this.ready, material.ready]); await Promise.all([this.ready, material.ready]);
if (material.scene !== this.scene) if (material.scene !== this.scene) throw new Error("Material must belong to the same Scene");
throw new Error("Material must belong to the same Scene");
const list = Array.isArray(faces) ? faces : [faces]; const list = Array.isArray(faces) ? faces : [faces];
const maximum = Math.max(...list); const maximum = Math.max(...list);
const array = await this.scene.ensureRows( const array = await this.scene.ensureRows(`mesh.${this.id}.faceMaterials`, maximum + 1, 16, "u32");
`mesh.${this.id}.faceMaterials`,
maximum + 1,
16,
"u32",
);
this.scene.markDirty(true); this.scene.markDirty(true);
for (const face of list) { for (const face of list) {
if (!Number.isInteger(face) || face < 0) throw new RangeError("face"); if (!Number.isInteger(face) || face < 0) throw new RangeError("face");
@@ -155,10 +120,7 @@ export class Mesh extends Node {
minimum[lane] = Math.min(minimum[lane], positions[index + lane]); minimum[lane] = Math.min(minimum[lane], positions[index + lane]);
maximum[lane] = Math.max(maximum[lane], positions[index + lane]); maximum[lane] = Math.max(maximum[lane], positions[index + lane]);
} }
this.scene this.scene.array("bounds").row(this.id).set([...minimum, 0, ...maximum, 0]);
.array("bounds")
.row(this.id)
.set([...minimum, 0, ...maximum, 0]);
} }
override async dispose() { override async dispose() {
File diff suppressed because it is too large Load Diff
+28 -51
View File
@@ -45,45 +45,30 @@ export class ArcRotateCamera extends Camera {
}); });
} }
get alpha() { get alpha() { return this.cameraRow()[13]; }
return this.cameraRow()[13];
}
set alpha(value: number) { set alpha(value: number) {
const row = this.cameraRow(); const row = this.cameraRow();
this.#updateTransform(() => { this.#updateTransform(() => { row[13] = value; });
row[13] = value;
});
}
get beta() {
return this.cameraRow()[14];
} }
get beta() { return this.cameraRow()[14]; }
set beta(value: number) { set beta(value: number) {
const row = this.cameraRow(); const row = this.cameraRow();
this.#updateTransform(() => { this.#updateTransform(() => {
row[14] = Math.min(Math.PI - 0.001, Math.max(0.001, value)); row[14] = Math.min(Math.PI - 0.001, Math.max(0.001, value));
}); });
} }
get radius() { get radius() { return this.cameraRow()[15]; }
return this.cameraRow()[15];
}
set radius(value: number) { set radius(value: number) {
const row = this.cameraRow(); const row = this.cameraRow();
this.#updateTransform(() => { this.#updateTransform(() => { row[15] = Math.max(0.01, value); });
row[15] = Math.max(0.01, value);
});
} }
get target() { get target() { return this.#target; }
return this.#target;
}
set target(value: Node | undefined) { set target(value: Node | undefined) {
if (value && (value.scene !== this.scene || value.id < 0)) if (value && (value.scene !== this.scene || value.id < 0)) throw new Error("Target must be a ready Node in this Scene");
throw new Error("Target must be a ready Node in this Scene");
this.#target = value; this.#target = value;
const row = this.cameraRow(); const row = this.cameraRow();
this.#updateTransform(() => { this.#updateTransform(() => { row[12] = value ? value.id + 1 : 0; });
row[12] = value ? value.id + 1 : 0;
});
} }
attachControls(controls: ArcRotateControls) { attachControls(controls: ArcRotateControls) {
@@ -95,12 +80,9 @@ export class ArcRotateCamera extends Camera {
controls.element.addEventListener("pointerup", this.#up); controls.element.addEventListener("pointerup", this.#up);
controls.element.addEventListener("pointercancel", this.#up); controls.element.addEventListener("pointercancel", this.#up);
controls.element.addEventListener("contextmenu", this.#contextMenu); controls.element.addEventListener("contextmenu", this.#contextMenu);
controls.element.addEventListener("wheel", this.#wheel, { controls.element.addEventListener("wheel", this.#wheel, { passive: false });
passive: false,
});
} }
if (controls.controller) if (controls.controller) this.#frame = requestAnimationFrame(this.#pollController);
this.#frame = requestAnimationFrame(this.#pollController);
return this; return this;
} }
@@ -114,7 +96,7 @@ export class ArcRotateCamera extends Camera {
element.removeEventListener("contextmenu", this.#contextMenu); element.removeEventListener("contextmenu", this.#contextMenu);
element.removeEventListener("wheel", this.#wheel); element.removeEventListener("wheel", this.#wheel);
} }
if (this.#frame) cancelAnimationFrame(this.#frame); cancelAnimationFrame(this.#frame);
this.#frame = 0; this.#frame = 0;
this.#pointer = undefined; this.#pointer = undefined;
this.#controls = undefined; this.#controls = undefined;
@@ -124,7 +106,11 @@ export class ArcRotateCamera extends Camera {
const row = this.cameraRow(); const row = this.cameraRow();
if (!this.#target) return row.subarray(16, 19); if (!this.#target) return row.subarray(16, 19);
const position = this.#target.position; 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) { #updateTransform(change?: () => void) {
@@ -144,16 +130,10 @@ export class ArcRotateCamera extends Camera {
#down = (event: PointerEvent) => { #down = (event: PointerEvent) => {
event.preventDefault(); event.preventDefault();
this.#pointer = { this.#pointer = { x: event.clientX, y: event.clientY, button: event.button };
x: event.clientX,
y: event.clientY,
button: event.button,
};
this.#controls?.element.setPointerCapture?.(event.pointerId); this.#controls?.element.setPointerCapture?.(event.pointerId);
}; };
#up = () => { #up = () => { this.#pointer = undefined; };
this.#pointer = undefined;
};
#contextMenu = (event: Event) => event.preventDefault(); #contextMenu = (event: Event) => event.preventDefault();
#move = (event: PointerEvent) => { #move = (event: PointerEvent) => {
if (!this.#pointer || !this.#controls) return; if (!this.#pointer || !this.#controls) return;
@@ -164,17 +144,9 @@ export class ArcRotateCamera extends Camera {
if (this.#pointer.button === 2) { if (this.#pointer.button === 2) {
const row = this.cameraRow(); const row = this.cameraRow();
this.#updateTransform(() => { this.#updateTransform(() => {
const element = this.#controls!.element; const speed = this.#controls?.panSpeed ?? 0.005;
const verticalExtent = const horizontal = -x * speed;
this.projection === "orthographic" const vertical = y * speed;
? 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 sinAlpha = Math.sin(row[13]);
const cosAlpha = Math.cos(row[13]); const cosAlpha = Math.cos(row[13]);
const sinBeta = Math.sin(row[14]); const sinBeta = Math.sin(row[14]);
@@ -213,12 +185,17 @@ export class ArcRotateCamera extends Camera {
row[13] += (pad.axes[2] ?? pad.axes[0] ?? 0) * 0.03; row[13] += (pad.axes[2] ?? pad.axes[0] ?? 0) * 0.03;
row[14] = Math.min( row[14] = Math.min(
Math.PI - 0.001, 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( row[15] = Math.max(
0.01, 0.01,
row[15] + 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,
); );
}); });
} }
+4 -32
View File
@@ -1,8 +1,5 @@
import { Mesh } from "../Mesh"; import { Mesh } from "../Mesh";
import type { Scene } from "../Scene"; 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 { PBRMaterial } from "../materials/PBRMaterial";
import { Texture } from "../materials/Texture"; import { Texture } from "../materials/Texture";
@@ -52,20 +49,12 @@ export async function importGltf(scene: Scene, url: string | URL) {
for (const material of result.materials) { for (const material of result.materials) {
if (material.baseColorTexture >= 0) srgb.add(material.baseColorTexture); if (material.baseColorTexture >= 0) srgb.add(material.baseColorTexture);
if (material.emissiveTexture >= 0) srgb.add(material.emissiveTexture); 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( textures = result.textures.map(
( ({ image }: { image: ImageBitmap }, index: number) =>
{ image, sampler }: { image: ImageBitmap; sampler: any },
index: number,
) =>
new Texture(scene, { new Texture(scene, {
source: image, source: image,
format: srgb.has(index) ? "rgba8unorm-srgb" : "rgba8unorm", format: srgb.has(index) ? "rgba8unorm-srgb" : "rgba8unorm",
sampler,
}), }),
); );
await Promise.all(textures.map((texture: Texture) => texture.ready)); await Promise.all(textures.map((texture: Texture) => texture.ready));
@@ -74,19 +63,10 @@ export async function importGltf(scene: Scene, url: string | URL) {
new PBRMaterial(scene, { new PBRMaterial(scene, {
...options, ...options,
baseColorTexture: textures[options.baseColorTexture], baseColorTexture: textures[options.baseColorTexture],
metallicRoughnessTexture: textures[options.metallicRoughnessTexture], metallicRoughnessTexture:
textures[options.metallicRoughnessTexture],
normalTexture: textures[options.normalTexture], normalTexture: textures[options.normalTexture],
emissiveTexture: textures[options.emissiveTexture], 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)); await Promise.all(materials.map((material: PBRMaterial) => material.ready));
@@ -103,20 +83,12 @@ export async function importGltf(scene: Scene, url: string | URL) {
...(primitive.normals ? { normals: primitive.normals } : {}), ...(primitive.normals ? { normals: primitive.normals } : {}),
...(primitive.tangents ? { tangents: primitive.tangents } : {}), ...(primitive.tangents ? { tangents: primitive.tangents } : {}),
...(primitive.uvs ? { uvs: primitive.uvs } : {}), ...(primitive.uvs ? { uvs: primitive.uvs } : {}),
...(primitive.uvs2 ? { uvs2: primitive.uvs2 } : {}),
...(primitive.colors ? { colors: primitive.colors } : {}), ...(primitive.colors ? { colors: primitive.colors } : {}),
}, },
}), }),
); );
await Promise.all(meshes.map((mesh) => mesh.ready)); await Promise.all(meshes.map((mesh) => mesh.ready));
const lights = (result.lights ?? []).map((options: any) => { return meshes;
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( await Promise.all(
result.textures.map((_: unknown, index: number) => result.textures.map((_: unknown, index: number) =>
+134 -718
View File
@@ -1,15 +1,7 @@
export {}; export {};
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const widths: Record<string, number> = { const widths: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
SCALAR: 1,
VEC2: 2,
VEC3: 3,
VEC4: 4,
MAT2: 4,
MAT3: 9,
MAT4: 16,
};
const sizes: Record<number, number> = { const sizes: Record<number, number> = {
5120: 1, 5120: 1,
5121: 1, 5121: 1,
@@ -37,165 +29,79 @@ function component(view: DataView, offset: number, type: number) {
throw new Error("GLTF_COMPONENT"); throw new Error("GLTF_COMPONENT");
} }
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; 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,
];
}
function multiply(a: ArrayLike<number>, b: ArrayLike<number>) { function multiply(a: number[], b: number[]) {
const result = new Array<number>(16); return [
for (let column = 0; column < 4; column++) a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
for (let row = 0; row < 4; row++) { a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
let value = 0; a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
for (let lane = 0; lane < 4; lane++) a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
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 result; return { position: matrix.slice(12, 15), rotor, scale };
} }
function transform(node: any) { function transform(node: any) {
if (node.matrix) return Array.from(node.matrix); return node.matrix
const [x, y, z, w] = node.rotation ?? [0, 0, 0, 1]; ? decompose(node.matrix)
const [sx, sy, sz] = node.scale ?? [1, 1, 1]; : {
const [tx, ty, tz] = node.translation ?? [0, 0, 0]; position: node.translation ?? [0, 0, 0],
const xx = x * x; rotor: node.rotation ?? [0, 0, 0, 1],
const yy = y * y; scale: node.scale ?? [1, 1, 1],
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 determinant(matrix: ArrayLike<number>) { function compose(parent: any, local: any) {
return ( const position = rotate(
matrix[0] * (matrix[5] * matrix[10] - matrix[6] * matrix[9]) - parent.rotor,
matrix[4] * (matrix[1] * matrix[10] - matrix[2] * matrix[9]) + local.position.map(
matrix[8] * (matrix[1] * matrix[6] - matrix[2] * matrix[5]) (value: number, lane: number) => value * parent.scale[lane],
),
); );
} return {
position: position.map((value, lane) => value + parent.position[lane]),
function point(matrix: ArrayLike<number>, source: ArrayLike<number>) { rotor: multiply(parent.rotor, local.rotor),
return [ scale: local.scale.map(
matrix[0] * source[0] + (value: number, lane: number) => value * parent.scale[lane],
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) { function parse(bytes: Uint8Array) {
@@ -221,127 +127,7 @@ function parse(bytes: Uint8Array) {
return { document, binary }; return { document, binary };
} }
function computeNormals(positions: Float32Array, indices: Uint32Array) { async function load(url: string) {
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); const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP_${response.status}`); if (!response.ok) throw new Error(`HTTP_${response.status}`);
const { document, binary } = parse( const { document, binary } = parse(
@@ -373,75 +159,37 @@ export async function loadGltf(url: string) {
return result.blob(); 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( const textures = await Promise.all(
(document.textures ?? []).map(async (texture: any) => ({ (document.textures ?? []).map(async (texture: any) => ({
image: await createImageBitmap(imageBlobs[texture.source]), 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 accessor = (id: number, integer = false) => {
const source = document.accessors[id]; const source = document.accessors[id];
const width = widths[source.type]; const width = widths[source.type];
const size = sizes[source.componentType]; 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 bufferView = document.bufferViews[source.bufferView];
const bytes = buffers[bufferView.buffer]; const bytes = buffers[bufferView.buffer];
const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0); const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0);
const stride = bufferView.byteStride ?? width * size; const stride = bufferView.byteStride ?? width * size;
const view = new DataView( const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
bytes.buffer, const values = integer
bytes.byteOffset, ? new Uint32Array(source.count * width)
bytes.byteLength, : new Float32Array(source.count * width);
);
if ( if (
!source.sparse &&
!source.normalized && !source.normalized &&
stride === width * size && stride === width * size &&
(bytes.byteOffset + start) % size === 0 (bytes.byteOffset + start) % size === 0
) { ) {
values.set( const packed = new arrays[source.componentType](
new arrays[source.componentType](
bytes.buffer, bytes.buffer,
bytes.byteOffset + start, bytes.byteOffset + start,
source.count * width, source.count * width,
),
); );
} else values.set(packed);
return values;
}
for (let item = 0; item < source.count; item++) for (let item = 0; item < source.count; item++)
for (let lane = 0; lane < width; lane++) { for (let lane = 0; lane < width; lane++) {
let value = component( let value = component(
@@ -449,437 +197,105 @@ export async function loadGltf(url: string) {
start + item * stride + lane * size, start + item * stride + lane * size,
source.componentType, source.componentType,
); );
if (!integer && source.normalized) if (!integer && source.normalized) {
value = normalized(value, source.componentType); const maximum =
source.componentType === 5121
? 255
: source.componentType === 5123
? 65535
: 1;
value /= maximum;
}
values[item * width + lane] = value; 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; return values;
}; };
const textureInfo = (info: any) => {
if (!info) return undefined;
const extension = info.extensions?.KHR_texture_transform ?? {};
return {
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 materials = (document.materials ?? []).map((material: any) => {
const extensions = material.extensions ?? {};
const specGloss = extensions.KHR_materials_pbrSpecularGlossiness;
const pbr = material.pbrMetallicRoughness ?? {}; 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 { return {
baseColor: specGloss?.diffuseFactor ?? baseColor: pbr.baseColorFactor ?? [1, 1, 1, 1],
pbr.baseColorFactor ?? [1, 1, 1, 1], metallic: pbr.metallicFactor ?? 0,
metallic: specGloss ? 0 : (pbr.metallicFactor ?? 1), roughness: pbr.roughnessFactor ?? 0.7,
roughness: specGloss
? 1 - (specGloss.glossinessFactor ?? 1)
: (pbr.roughnessFactor ?? 1),
emissive: material.emissiveFactor ?? [0, 0, 0], emissive: material.emissiveFactor ?? [0, 0, 0],
normalScale: material.normalTexture?.scale ?? 1,
alphaCutoff: material.alphaCutoff ?? 0.5, alphaCutoff: material.alphaCutoff ?? 0.5,
flags, baseColorTexture: pbr.baseColorTexture?.index ?? -1,
occlusionStrength: material.occlusionTexture?.strength ?? 1, metallicRoughnessTexture: pbr.metallicRoughnessTexture?.index ?? -1,
transmission: transmission.transmissionFactor ?? 0, normalTexture: material.normalTexture?.index ?? -1,
ior: extensions.KHR_materials_ior?.ior ?? 1.5, emissiveTexture: material.emissiveTexture?.index ?? -1,
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 primitives: any[] = [];
const emitMesh = (meshId: number, nodeId: number, matrix: number[]) => { const emitMesh = (meshId: number, transform: any) => {
const mesh = document.meshes?.[meshId]; 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 ?? []) { for (const primitive of mesh?.primitives ?? []) {
if ( if (
(primitive.mode ?? 4) !== 4 || (primitive.mode ?? 4) !== 4 ||
primitive.attributes.POSITION === undefined primitive.attributes.POSITION === undefined
) )
continue; continue;
const positions = accessor(primitive.attributes.POSITION) as Float32Array; const positions = accessor(primitive.attributes.POSITION);
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 = const indices =
primitive.indices === undefined primitive.indices === undefined
? Uint32Array.from( ? Uint32Array.from(
{ length: positions.length / 3 }, { length: positions.length / 3 },
(_, index) => index, (_, index) => index,
) )
: (accessor(primitive.indices, true) as Uint32Array); : accessor(primitive.indices, true);
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({ primitives.push({
positions, positions,
normals,
tangents,
uvs,
uvs2,
indices, indices,
...(colorAccessor === undefined ...(primitive.attributes.NORMAL === undefined
? {} ? {}
: { : { normals: accessor(primitive.attributes.NORMAL) }),
colors: color4( ...(primitive.attributes.TANGENT === undefined
accessor(colorAccessor) as Float32Array, ? {}
widths[document.accessors[colorAccessor].type], : { tangents: accessor(primitive.attributes.TANGENT) }),
), ...(primitive.attributes.TEXCOORD_0 === undefined
}), ? {}
: { uvs: accessor(primitive.attributes.TEXCOORD_0) }),
...(primitive.attributes.COLOR_0 === undefined
? {}
: { colors: accessor(primitive.attributes.COLOR_0) }),
material: primitive.material ?? -1, material: primitive.material ?? -1,
...transform,
});
}
};
const nodes = document.nodes ?? [];
const scene = document.scenes?.[document.scene ?? 0];
const children = new Set(nodes.flatMap((node: any) => node.children ?? []));
const roots =
scene?.nodes ??
nodes
.map((_: any, id: number) => id)
.filter((id: number) => !children.has(id));
const visit = (
id: number,
parent = {
position: [0, 0, 0], position: [0, 0, 0],
rotor: [0, 0, 0, 1], rotor: [0, 0, 0, 1],
scale: [1, 1, 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);
}; };
for (const root of roots) visit(root);
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 (let id = 0; id < nodes.length; id++) emitNode(id);
if (!nodes.length) if (!nodes.length)
for (let id = 0; id < (document.meshes ?? []).length; id++) for (let id = 0; id < (document.meshes ?? []).length; id++)
emitMesh(id, -1, identity); emitMesh(id, {});
const lightDefinitions = return { materials, primitives, textures };
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 }) => {
addEventListener("message", async ({ data }) => {
try { try {
const result = await loadGltf(data.url); const result = await load(data.url);
const transfers = result.primitives.flatMap((primitive: any) => const transfers = result.primitives.flatMap((primitive: any) =>
["positions", "indices", "normals", "tangents", "uvs", "uvs2", "colors"] ["positions", "indices", "normals", "tangents", "uvs", "colors"]
.map((name) => primitive[name]?.buffer) .map((name) => primitive[name]?.buffer)
.filter(Boolean), .filter(Boolean),
); );
@@ -891,4 +307,4 @@ if (typeof addEventListener === "function")
error: error instanceof Error ? error.message : "GLTF_IMPORT", error: error instanceof Error ? error.message : "GLTF_IMPORT",
}); });
} }
}); });
+9 -46
View File
@@ -1,62 +1,25 @@
import { Node, type NodeOptions } from "../Node"; import { Node, type NodeOptions } from "../Node";
import type { Scene } from "../Scene"; import type { Scene } from "../Scene";
export type PointLightOptions = NodeOptions & { export type PointLightOptions = NodeOptions & { color?: ArrayLike<number>; intensity?: number; range?: number };
color?: ArrayLike<number>;
intensity?: number;
range?: number;
radius?: number;
};
export class PointLight extends Node { export class PointLight extends Node {
constructor(scene: Scene, options: PointLightOptions = {}) { constructor(scene: Scene, options: PointLightOptions = {}) {
super(scene, options); super(scene, options);
const nodeReady = this.ready; const nodeReady = this.ready;
this.ready = nodeReady.then(async () => { this.ready = nodeReady.then(async () => {
const array = await scene.ensureRows( const array = await scene.ensureRows("pointLights", this.id + 1, 32, "f32");
"pointLights", array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, options.range ?? 10, 1, 0, 0]);
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,
]);
return this; return this;
}); });
} }
get color() { get color() { return this.scene.array("pointLights").row(this.id).subarray(0, 3); }
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 color(value: ArrayLike<number>) { set intensity(value: number) { this.scene.array("pointLights").row(this.id)[3] = value; }
this.scene.array("pointLights").row(this.id).set(value, 0); 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 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() { override async dispose() {
await this.ready; await this.ready;
+15 -157
View File
@@ -1,13 +1,6 @@
import type { Scene } from "../Scene"; import type { Scene } from "../Scene";
import type { Texture } from "./Texture"; import type { Texture } from "./Texture";
export type PBRTextureInfo = {
texCoord?: number;
offset?: ArrayLike<number>;
scale?: ArrayLike<number>;
rotation?: number;
};
export type PBRMaterialOptions = { export type PBRMaterialOptions = {
baseColor?: ArrayLike<number>; baseColor?: ArrayLike<number>;
metallic?: number; metallic?: number;
@@ -19,40 +12,9 @@ export type PBRMaterialOptions = {
metallicRoughnessTexture?: Texture; metallicRoughnessTexture?: Texture;
normalTexture?: Texture; normalTexture?: Texture;
emissiveTexture?: 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, extensions, texture IDs, and UV transforms. */ /** Conventional PBR values and texture IDs stored in two shared SOA rows. */
export class PBRMaterial { export class PBRMaterial {
readonly scene: Scene; readonly scene: Scene;
id = -1; id = -1;
@@ -66,152 +28,48 @@ export class PBRMaterial {
options.metallicRoughnessTexture, options.metallicRoughnessTexture,
options.normalTexture, options.normalTexture,
options.emissiveTexture, 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); ].filter((texture): texture is Texture => texture !== undefined);
this.ready = Promise.all(textures.map((texture) => texture.ready)).then( this.ready = Promise.all(textures.map((texture) => texture.ready)).then(async () => {
async () => {
const id = await scene.allocateMaterial(); const id = await scene.allocateMaterial();
this.id = id; this.id = id;
const color = Array.from(options.baseColor ?? [1, 1, 1, 1]); const color = Array.from(options.baseColor ?? [1, 1, 1, 1]);
const emissive = Array.from(options.emissive ?? [0, 0, 0]); const emissive = Array.from(options.emissive ?? [0, 0, 0]);
const attenuation = Array.from(options.attenuationColor ?? [1, 1, 1]); if (color.length !== 4 || emissive.length !== 3) throw new TypeError("PBR material vectors");
const secondary = Array.from( scene.array("materials").write(id, [
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, ...color,
options.metallic ?? 0, options.metallic ?? 0,
options.roughness ?? 0.7, options.roughness ?? 0.7,
...emissive, ...emissive,
options.normalScale ?? 1, options.normalScale ?? 1,
options.alphaCutoff ?? 0.5, options.alphaCutoff ?? 0.5,
options.flags ?? 0, 0,
]); ]);
scene scene.array("materialTextures").write(id, [
.array("materialTextures")
.write(id, [
(options.baseColorTexture?.id ?? -1) + 1, (options.baseColorTexture?.id ?? -1) + 1,
(options.metallicRoughnessTexture?.id ?? -1) + 1, (options.metallicRoughnessTexture?.id ?? -1) + 1,
(options.normalTexture?.id ?? -1) + 1, (options.normalTexture?.id ?? -1) + 1,
(options.emissiveTexture?.id ?? -1) + 1, (options.emissiveTexture?.id ?? -1) + 1,
(options.occlusionTexture?.id ?? -1) + 1, 0, 0, 0, 0,
(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; return this;
}, });
);
} }
#values() { #values() {
if (this.id < 0) if (this.id < 0) throw new Error("Await material.ready before reading or writing it");
throw new Error("Await material.ready before reading or writing it");
return this.scene.array("materials").row(this.id); return this.scene.array("materials").row(this.id);
} }
get baseColor() { get baseColor() { return this.#values().subarray(0, 4); }
return this.#values().subarray(0, 4);
}
set baseColor(value: ArrayLike<number>) { set baseColor(value: ArrayLike<number>) {
if (value.length !== 4) throw new RangeError("baseColor"); if (value.length !== 4) throw new RangeError("baseColor");
this.#values().set(value, 0); this.#values().set(value, 0);
} }
get metallic() { get metallic() { return this.#values()[4]; }
return this.#values()[4]; set metallic(value: number) { this.#values()[4] = value; }
} get roughness() { return this.#values()[5]; }
set metallic(value: number) { set roughness(value: number) { this.#values()[5] = value; }
this.#values()[4] = value; get emissive() { return this.#values().subarray(6, 9); }
}
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>) { set emissive(value: ArrayLike<number>) {
if (value.length !== 3) throw new RangeError("emissive"); if (value.length !== 3) throw new RangeError("emissive");
this.#values().set(value, 6); this.#values().set(value, 6);
-3
View File
@@ -1,5 +1,4 @@
import type { Scene } from "../Scene"; import type { Scene } from "../Scene";
import type { GraphSampler } from "../ComputePass";
export type TextureOptions = { export type TextureOptions = {
source?: string | ImageBitmap; source?: string | ImageBitmap;
@@ -8,7 +7,6 @@ export type TextureOptions = {
usage?: string[]; usage?: string[];
mipmaps?: boolean; mipmaps?: boolean;
transient?: boolean; transient?: boolean;
sampler?: Omit<GraphSampler, "id">;
}; };
let nextTextureName = 1; let nextTextureName = 1;
@@ -54,7 +52,6 @@ export class Texture {
]), ]),
], ],
transient: options.transient ?? false, transient: options.transient ?? false,
sampler: options.sampler,
}); });
this.id = registration.number; this.id = registration.number;
await registration.ready; await registration.ready;
-220
View File
@@ -1,220 +0,0 @@
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 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) {
const id = this.#nextObjects.get(name) ?? 0;
this.#nextObjects.set(name, id + 1);
const rows = this.array(name);
rows.resize(id + 1);
return Promise.resolve(id);
}
deleteObject() {
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;
}
}
-250
View File
@@ -1,250 +0,0 @@
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 { createScene, 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]);
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);
assert.equal(
scene.array(`mesh.${clone.id}.faceMaterials`).row(0)[0],
firstMaterial.id + 1,
);
});
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 pans one projected world pixel per pointer pixel", async () => {
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 });
close(camera.position, [-2, 1, 10]);
camera.detachControls();
});
-145
View File
@@ -1,145 +0,0 @@
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,
});
}
});
-359
View File
@@ -1,359 +0,0 @@
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, []);
});
-46
View File
@@ -1,46 +0,0 @@
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 });
}
-351
View File
@@ -1,351 +0,0 @@
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 {
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();
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 passes = forwardPasses(graph);
assert.equal(pbrPipelines(graph).length, 1);
assert.equal(passes.length, 2);
assert.deepEqual(
passes.map((pass: any) => pass.draw.firstInstance & 0xffff),
[firstMesh.id, secondMesh.id],
);
assert.deepEqual(
passes.map((pass: any) => (pass.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, 2);
});
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);
});
-55
View File
@@ -1,55 +0,0 @@
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 });
}
-18
View File
@@ -1,18 +0,0 @@
# 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.
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
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Some files were not shown because too many files have changed in this diff Show More