Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1fd4df540 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ core/pkg
|
|||||||
server/data
|
server/data
|
||||||
docs
|
docs
|
||||||
coredocs
|
coredocs
|
||||||
|
vendor
|
||||||
*.log
|
*.log
|
||||||
*.local
|
*.local
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -132,6 +132,17 @@ 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"
|
||||||
@@ -145,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",
|
||||||
@@ -727,6 +739,7 @@ name = "yawn-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from "./catalog";
|
|
||||||
export * from "./compiler";
|
|
||||||
export * from "./editor";
|
|
||||||
export * from "./physical";
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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,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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export type GraphBuffer = {
|
|||||||
id: string;
|
id: string;
|
||||||
array: string;
|
array: string;
|
||||||
usage?: string[];
|
usage?: string[];
|
||||||
sync?: "frame" | "manual";
|
sync?: "frame" | "loadout";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GraphTexture = {
|
export type GraphTexture = {
|
||||||
|
|||||||
@@ -2,27 +2,20 @@ 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 VertexStreams = Partial<Record<VertexKind, ArrayLike<number>>>;
|
|
||||||
export type MeshOptions = NodeOptions & {
|
export type MeshOptions = NodeOptions & {
|
||||||
geometryId?: number;
|
geometryId?: number;
|
||||||
material?: PBRMaterial;
|
material?: PBRMaterial;
|
||||||
vertexData?: VertexStreams;
|
vertexData?: Partial<Record<VertexKind, ArrayLike<number>>>;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A renderable Node; clones share geometry until either clone mutates vertex data. */
|
/** A renderable Node; clones share geometry until either clone mutates vertex data. */
|
||||||
export class Mesh extends Node {
|
export class Mesh extends Node {
|
||||||
geometryId: number;
|
geometryId: number;
|
||||||
|
vertexCount = 0;
|
||||||
|
indexCount = 0;
|
||||||
instanceOf = -1;
|
instanceOf = -1;
|
||||||
readonly faceMaterials = new Map<number, number>();
|
readonly faceMaterials = new Map<number, number>();
|
||||||
#registered = false;
|
#registered = false;
|
||||||
@@ -33,18 +26,18 @@ export class Mesh extends Node {
|
|||||||
const nodeReady = this.ready;
|
const nodeReady = this.ready;
|
||||||
this.ready = nodeReady.then(async () => {
|
this.ready = nodeReady.then(async () => {
|
||||||
if (options.material) await options.material.ready;
|
if (options.material) await options.material.ready;
|
||||||
this.instanceOf =
|
await scene.ensureRows(`mesh.${this.id}.faceMaterials`, 1, 16, "u32");
|
||||||
options.geometryId === undefined ? this.id : this.geometryId;
|
this.vertexCount = Number(scene.geometryData(this.geometryId, "positions")?.length ?? 0) / 3;
|
||||||
scene
|
this.indexCount = Number(scene.geometryData(this.geometryId, "indices")?.length ?? 0);
|
||||||
.array("meshInfo")
|
this.instanceOf = options.geometryId === undefined ? this.id : this.geometryId;
|
||||||
.write(this.id, [
|
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,
|
||||||
this.instanceOf,
|
this.instanceOf,
|
||||||
]);
|
]);
|
||||||
if (options.vertexData)
|
for (const [kind, data] of Object.entries(options.vertexData ?? {}))
|
||||||
await this.#setVertexData(options.vertexData, false);
|
await this.#setVertexData(kind as VertexKind, data!, false);
|
||||||
this.#writeBounds();
|
this.#writeBounds();
|
||||||
this.#registered = true;
|
this.#registered = true;
|
||||||
await scene.registerMesh(this);
|
await scene.registerMesh(this);
|
||||||
@@ -52,14 +45,6 @@ export class Mesh extends Node {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get vertexCount() {
|
|
||||||
return this.scene.geometry(this.geometryId).vertexCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
get indexCount() {
|
|
||||||
return this.scene.geometry(this.geometryId).indexCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isVisible() {
|
get isVisible() {
|
||||||
if (this.id < 0) throw new Error("Await mesh.ready before reading it");
|
if (this.id < 0) throw new Error("Await mesh.ready before reading it");
|
||||||
return this.scene.array("meshInfo").row(this.id)[2] !== 0;
|
return this.scene.array("meshInfo").row(this.id)[2] !== 0;
|
||||||
@@ -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);
|
||||||
@@ -87,52 +71,41 @@ export class Mesh extends Node {
|
|||||||
return new Mesh(this.scene, { ...options, geometryId: this.geometryId });
|
return new Mesh(this.scene, { ...options, geometryId: this.geometryId });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replaces one stream, or several at once, repacking the geometry a single time. */
|
async setVertexData(kind: VertexKind, data: ArrayLike<number>) {
|
||||||
async setVertexData(
|
if (!VertexKinds.includes(kind)) throw new TypeError(`Unknown vertex kind: ${kind}`);
|
||||||
kind: VertexKind | VertexStreams,
|
|
||||||
data?: ArrayLike<number>,
|
|
||||||
) {
|
|
||||||
const streams = typeof kind === "string" ? { [kind]: data } : kind;
|
|
||||||
for (const name of Object.keys(streams))
|
|
||||||
if (!VertexKinds.includes(name as VertexKind))
|
|
||||||
throw new TypeError(`Unknown vertex kind: ${name}`);
|
|
||||||
await this.ready;
|
await this.ready;
|
||||||
await this.#setVertexData(streams, true);
|
await this.#setVertexData(kind, data, true);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #setVertexData(streams: VertexStreams, makeUnique: boolean) {
|
async #setVertexData(kind: VertexKind, data: ArrayLike<number>, makeUnique: boolean) {
|
||||||
if (makeUnique && this.scene.geometry(this.geometryId).references > 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);
|
||||||
this.scene.releaseGeometry(original);
|
this.scene.releaseGeometry(original);
|
||||||
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,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
await this.scene.setVertexData(this.geometryId, streams, makeUnique);
|
if (kind === "positions") this.vertexCount = data.length / 3;
|
||||||
if (streams.positions) this.#writeBounds();
|
if (kind === "indices") this.indexCount = data.length;
|
||||||
|
await this.scene.setVertexData(this.geometryId, kind, data, makeUnique);
|
||||||
|
if (kind === "positions") this.#writeBounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) {
|
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 array = await this.scene.ensureRows(`mesh.${this.id}.faceMaterials`, maximum + 1, 16, "u32");
|
||||||
|
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");
|
||||||
|
array.row(face)[0] = material.id + 1;
|
||||||
this.faceMaterials.set(face, material.id);
|
this.faceMaterials.set(face, material.id);
|
||||||
}
|
}
|
||||||
this.scene.markDirty(true);
|
|
||||||
await this.scene.updateRenderGraph();
|
await this.scene.updateRenderGraph();
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -147,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() {
|
||||||
@@ -160,6 +130,7 @@ export class Mesh extends Node {
|
|||||||
this.#registered = false;
|
this.#registered = false;
|
||||||
await this.scene.unregisterMesh(this);
|
await this.scene.unregisterMesh(this);
|
||||||
}
|
}
|
||||||
|
await this.scene.core.deleteRows(`mesh.${this.id}.faceMaterials`);
|
||||||
await super.dispose();
|
await super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ export class ArcRotateCamera extends Camera {
|
|||||||
#controls?: ArcRotateControls;
|
#controls?: ArcRotateControls;
|
||||||
#pointer?: { x: number; y: number; button: number };
|
#pointer?: { x: number; y: number; button: number };
|
||||||
#frame = 0;
|
#frame = 0;
|
||||||
#inertiaFrame = 0;
|
|
||||||
#lastFrame = 0;
|
|
||||||
#motion = [0, 0, 0, 0, 0]; // orbit x/y, pan x/y, logarithmic zoom
|
|
||||||
|
|
||||||
constructor(scene: Scene, options: ArcRotateCameraOptions = {}) {
|
constructor(scene: Scene, options: ArcRotateCameraOptions = {}) {
|
||||||
super(scene, options);
|
super(scene, options);
|
||||||
@@ -48,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) {
|
||||||
@@ -98,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,11 +96,8 @@ 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;
|
||||||
if (this.#inertiaFrame) cancelAnimationFrame(this.#inertiaFrame);
|
|
||||||
this.#inertiaFrame = 0;
|
|
||||||
this.#motion.fill(0);
|
|
||||||
this.#pointer = undefined;
|
this.#pointer = undefined;
|
||||||
this.#controls = undefined;
|
this.#controls = undefined;
|
||||||
}
|
}
|
||||||
@@ -130,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) {
|
||||||
@@ -150,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;
|
||||||
@@ -167,27 +141,12 @@ export class ArcRotateCamera extends Camera {
|
|||||||
const y = event.clientY - this.#pointer.y;
|
const y = event.clientY - this.#pointer.y;
|
||||||
this.#pointer.x = event.clientX;
|
this.#pointer.x = event.clientX;
|
||||||
this.#pointer.y = event.clientY;
|
this.#pointer.y = event.clientY;
|
||||||
const offset = this.#pointer.button === 2 ? 2 : 0;
|
if (this.#pointer.button === 2) {
|
||||||
this.#motion[offset] += x;
|
|
||||||
this.#motion[offset + 1] += y;
|
|
||||||
this.#scheduleInertia();
|
|
||||||
};
|
|
||||||
|
|
||||||
#applyDrag(x: number, y: number, pan: boolean) {
|
|
||||||
if (pan) {
|
|
||||||
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]);
|
||||||
@@ -197,46 +156,26 @@ export class ArcRotateCamera extends Camera {
|
|||||||
row[18] -= horizontal * sinAlpha + vertical * cosBeta * cosAlpha;
|
row[18] -= horizontal * sinAlpha + vertical * cosBeta * cosAlpha;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const speed = this.#controls?.orbitSpeed ?? 0.005;
|
const speed = this.#controls.orbitSpeed ?? 0.005;
|
||||||
const row = this.cameraRow();
|
const row = this.cameraRow();
|
||||||
this.#updateTransform(() => {
|
this.#updateTransform(() => {
|
||||||
row[13] -= x * speed;
|
row[13] -= x * speed;
|
||||||
row[14] = Math.min(
|
row[14] = Math.min(
|
||||||
Math.PI - 0.001,
|
Math.PI - 0.001,
|
||||||
Math.max(0.001, row[14] - y * speed),
|
Math.max(0.001, row[14] + y * speed),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
#wheel = (event: WheelEvent) => {
|
#wheel = (event: WheelEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.#motion[4] += event.deltaY * (this.#controls?.zoomSpeed ?? 0.001);
|
const row = this.cameraRow();
|
||||||
this.#scheduleInertia();
|
this.#updateTransform(() => {
|
||||||
};
|
row[15] = Math.max(
|
||||||
|
0.01,
|
||||||
#scheduleInertia() {
|
row[15] * Math.exp(event.deltaY * (this.#controls?.zoomSpeed ?? 0.001)),
|
||||||
if (this.#inertiaFrame) return;
|
);
|
||||||
this.#lastFrame = performance.now();
|
|
||||||
this.#inertiaFrame = requestAnimationFrame(this.#tickInertia);
|
|
||||||
}
|
|
||||||
|
|
||||||
#tickInertia = (now: number) => {
|
|
||||||
this.#inertiaFrame = 0;
|
|
||||||
const fraction = -Math.expm1(-Math.max(0, now - this.#lastFrame) / 80);
|
|
||||||
this.#lastFrame = now;
|
|
||||||
const step = this.#motion.map((remaining, index) => {
|
|
||||||
const value =
|
|
||||||
Math.abs(remaining) < 0.00001 ? remaining : remaining * fraction;
|
|
||||||
this.#motion[index] -= value;
|
|
||||||
return value;
|
|
||||||
});
|
});
|
||||||
this.batchTransformChanges(() => {
|
|
||||||
if (step[0] || step[1]) this.#applyDrag(step[0], step[1], false);
|
|
||||||
if (step[2] || step[3]) this.#applyDrag(step[2], step[3], true);
|
|
||||||
if (step[4]) this.radius *= Math.exp(step[4]);
|
|
||||||
});
|
|
||||||
if (this.#motion.some(Boolean))
|
|
||||||
this.#inertiaFrame = requestAnimationFrame(this.#tickInertia);
|
|
||||||
};
|
};
|
||||||
#pollController = () => {
|
#pollController = () => {
|
||||||
const pad = navigator.getGamepads?.().find(Boolean);
|
const pad = navigator.getGamepads?.().find(Boolean);
|
||||||
@@ -246,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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -45,20 +42,6 @@ export async function importGltf(scene: Scene, url: string | URL) {
|
|||||||
await scene.reserve({
|
await scene.reserve({
|
||||||
nodes: result.primitives.length,
|
nodes: result.primitives.length,
|
||||||
materials: result.materials.length,
|
materials: result.materials.length,
|
||||||
vertices: result.primitives.reduce(
|
|
||||||
(total: number, primitive: any) => total + primitive.positions.length / 3,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
// Each geometry's index span is padded to whole rows of four.
|
|
||||||
indices: result.primitives.reduce(
|
|
||||||
(total: number, primitive: any) =>
|
|
||||||
total +
|
|
||||||
Math.ceil(
|
|
||||||
(primitive.indices?.length ?? primitive.positions.length / 3) / 4,
|
|
||||||
) *
|
|
||||||
4,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
let textures: Texture[] = [];
|
let textures: Texture[] = [];
|
||||||
const imported = await scene.batchGraphUpdates(async () => {
|
const imported = await scene.batchGraphUpdates(async () => {
|
||||||
@@ -66,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));
|
||||||
@@ -88,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));
|
||||||
@@ -117,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) =>
|
||||||
|
|||||||
@@ -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;
|
}
|
||||||
}
|
|
||||||
return result;
|
function decompose(matrix: number[]) {
|
||||||
|
const scale = [
|
||||||
|
Math.hypot(matrix[0], matrix[1], matrix[2]),
|
||||||
|
Math.hypot(matrix[4], matrix[5], matrix[6]),
|
||||||
|
Math.hypot(matrix[8], matrix[9], matrix[10]),
|
||||||
|
];
|
||||||
|
const [sx, sy, sz] = scale;
|
||||||
|
const [m00, m01, m02] = [matrix[0] / sx, matrix[4] / sy, matrix[8] / sz];
|
||||||
|
const [m10, m11, m12] = [matrix[1] / sx, matrix[5] / sy, matrix[9] / sz];
|
||||||
|
const [m20, m21, m22] = [matrix[2] / sx, matrix[6] / sy, matrix[10] / sz];
|
||||||
|
let rotor: number[];
|
||||||
|
if (m00 + m11 + m22 > 0) {
|
||||||
|
const s = Math.sqrt(1 + m00 + m11 + m22) * 2;
|
||||||
|
rotor = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, s / 4];
|
||||||
|
} else if (m00 > m11 && m00 > m22) {
|
||||||
|
const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
|
||||||
|
rotor = [s / 4, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s];
|
||||||
|
} else if (m11 > m22) {
|
||||||
|
const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
|
||||||
|
rotor = [(m01 + m10) / s, s / 4, (m12 + m21) / s, (m02 - m20) / s];
|
||||||
|
} else {
|
||||||
|
const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
|
||||||
|
rotor = [(m02 + m20) / s, (m12 + m21) / s, s / 4, (m10 - m01) / s];
|
||||||
|
}
|
||||||
|
return { position: matrix.slice(12, 15), rotor, scale };
|
||||||
}
|
}
|
||||||
|
|
||||||
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,522 +159,152 @@ 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 bufferView = document.bufferViews[source.bufferView];
|
||||||
|
const bytes = buffers[bufferView.buffer];
|
||||||
|
const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0);
|
||||||
|
const stride = bufferView.byteStride ?? width * size;
|
||||||
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
const values = integer
|
const values = integer
|
||||||
? new Uint32Array(source.count * width)
|
? new Uint32Array(source.count * width)
|
||||||
: new Float32Array(source.count * width);
|
: new Float32Array(source.count * width);
|
||||||
if (source.bufferView !== undefined) {
|
if (
|
||||||
const bufferView = document.bufferViews[source.bufferView];
|
!source.normalized &&
|
||||||
const bytes = buffers[bufferView.buffer];
|
stride === width * size &&
|
||||||
const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0);
|
(bytes.byteOffset + start) % size === 0
|
||||||
const stride = bufferView.byteStride ?? width * size;
|
) {
|
||||||
const view = new DataView(
|
const packed = new arrays[source.componentType](
|
||||||
bytes.buffer,
|
bytes.buffer,
|
||||||
bytes.byteOffset,
|
bytes.byteOffset + start,
|
||||||
bytes.byteLength,
|
source.count * width,
|
||||||
);
|
);
|
||||||
if (
|
values.set(packed);
|
||||||
!source.sparse &&
|
return values;
|
||||||
!source.normalized &&
|
|
||||||
stride === width * size &&
|
|
||||||
(bytes.byteOffset + start) % size === 0
|
|
||||||
) {
|
|
||||||
values.set(
|
|
||||||
new arrays[source.componentType](
|
|
||||||
bytes.buffer,
|
|
||||||
bytes.byteOffset + start,
|
|
||||||
source.count * width,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else
|
|
||||||
for (let item = 0; item < source.count; item++)
|
|
||||||
for (let lane = 0; lane < width; lane++) {
|
|
||||||
let value = component(
|
|
||||||
view,
|
|
||||||
start + item * stride + lane * size,
|
|
||||||
source.componentType,
|
|
||||||
);
|
|
||||||
if (!integer && source.normalized)
|
|
||||||
value = normalized(value, source.componentType);
|
|
||||||
values[item * width + lane] = value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (source.sparse) {
|
for (let item = 0; item < source.count; item++)
|
||||||
const sparse = source.sparse;
|
for (let lane = 0; lane < width; lane++) {
|
||||||
const indexView = document.bufferViews[sparse.indices.bufferView];
|
let value = component(
|
||||||
const indexBytes = buffers[indexView.buffer];
|
view,
|
||||||
const indexStart =
|
start + item * stride + lane * size,
|
||||||
(indexView.byteOffset ?? 0) + (sparse.indices.byteOffset ?? 0);
|
source.componentType,
|
||||||
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++) {
|
if (!integer && source.normalized) {
|
||||||
let value = component(
|
const maximum =
|
||||||
valueData,
|
source.componentType === 5121
|
||||||
valueStart + (item * width + lane) * size,
|
? 255
|
||||||
source.componentType,
|
: source.componentType === 5123
|
||||||
);
|
? 65535
|
||||||
if (!integer && source.normalized)
|
: 1;
|
||||||
value = normalized(value, source.componentType);
|
value /= maximum;
|
||||||
values[target * width + lane] = value;
|
|
||||||
}
|
}
|
||||||
|
values[item * 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,
|
||||||
position: [0, 0, 0],
|
...transform,
|
||||||
rotor: [0, 0, 0, 1],
|
|
||||||
scale: [1, 1, 1],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const emitNode = (id: number) => {
|
const nodes = document.nodes ?? [];
|
||||||
const node = nodes[id];
|
const scene = document.scenes?.[document.scene ?? 0];
|
||||||
if (node.mesh === undefined) return;
|
const children = new Set(nodes.flatMap((node: any) => node.children ?? []));
|
||||||
const instances = node.extensions?.EXT_mesh_gpu_instancing?.attributes;
|
const roots =
|
||||||
if (!instances) return emitMesh(node.mesh, id, world(id));
|
scene?.nodes ??
|
||||||
const translations =
|
nodes
|
||||||
instances.TRANSLATION === undefined
|
.map((_: any, id: number) => id)
|
||||||
? undefined
|
.filter((id: number) => !children.has(id));
|
||||||
: (accessor(instances.TRANSLATION) as Float32Array);
|
const visit = (
|
||||||
const rotations =
|
id: number,
|
||||||
instances.ROTATION === undefined
|
parent = {
|
||||||
? undefined
|
position: [0, 0, 0],
|
||||||
: (accessor(instances.ROTATION) as Float32Array);
|
rotor: [0, 0, 0, 1],
|
||||||
const scales =
|
scale: [1, 1, 1],
|
||||||
instances.SCALE === undefined
|
},
|
||||||
? undefined
|
) => {
|
||||||
: (accessor(instances.SCALE) as Float32Array);
|
const node = nodes[id] ?? {};
|
||||||
const count = translations
|
const world = compose(parent, transform(node));
|
||||||
? translations.length / 3
|
if (node.mesh !== undefined) emitMesh(node.mesh, world);
|
||||||
: rotations
|
for (const child of node.children ?? []) visit(child, world);
|
||||||
? 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);
|
for (const root of roots) visit(root);
|
||||||
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 load(data.url);
|
||||||
const result = await loadGltf(data.url);
|
const transfers = result.primitives.flatMap((primitive: any) =>
|
||||||
const transfers = result.primitives.flatMap((primitive: any) =>
|
["positions", "indices", "normals", "tangents", "uvs", "colors"]
|
||||||
["positions", "indices", "normals", "tangents", "uvs", "uvs2", "colors"]
|
.map((name) => primitive[name]?.buffer)
|
||||||
.map((name) => primitive[name]?.buffer)
|
.filter(Boolean),
|
||||||
.filter(Boolean),
|
);
|
||||||
);
|
transfers.push(...result.textures.map((texture: any) => texture.image));
|
||||||
transfers.push(...result.textures.map((texture: any) => texture.image));
|
(postMessage as any)({ request: data.request, result }, transfers);
|
||||||
(postMessage as any)({ request: data.request, result }, transfers);
|
} catch (error) {
|
||||||
} catch (error) {
|
postMessage({
|
||||||
postMessage({
|
request: data.request,
|
||||||
request: data.request,
|
error: error instanceof Error ? error.message : "GLTF_IMPORT",
|
||||||
error: error instanceof Error ? error.message : "GLTF_IMPORT",
|
});
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export class DirectionalLight extends Node {
|
|||||||
this.ready = nodeReady.then(async () => {
|
this.ready = nodeReady.then(async () => {
|
||||||
const array = await scene.ensureRows("directionalLights", this.id + 1, 32, "f32");
|
const array = await scene.ensureRows("directionalLights", this.id + 1, 32, "f32");
|
||||||
array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, 1, 0, 0, 0]);
|
array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, 1, 0, 0, 0]);
|
||||||
await scene.setLight("directional", this.id, true);
|
|
||||||
return this;
|
return this;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -22,7 +21,6 @@ export class DirectionalLight extends Node {
|
|||||||
await this.ready;
|
await this.ready;
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
this.scene.array("directionalLights").row(this.id).fill(0);
|
this.scene.array("directionalLights").row(this.id).fill(0);
|
||||||
await this.scene.setLight("directional", this.id, false);
|
|
||||||
await super.dispose();
|
await super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,30 @@
|
|||||||
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,
|
|
||||||
]);
|
|
||||||
await scene.setLight("point", this.id, true);
|
|
||||||
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;
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
this.scene.array("pointLights").row(this.id).fill(0);
|
this.scene.array("pointLights").row(this.id).fill(0);
|
||||||
await this.scene.setLight("point", this.id, false);
|
|
||||||
await super.dispose();
|
await super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export class SpotLight extends Node {
|
|||||||
...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1,
|
...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1,
|
||||||
options.range ?? 10, options.innerAngle ?? 0.35, options.outerAngle ?? 0.7, 1,
|
options.range ?? 10, options.innerAngle ?? 0.35, options.outerAngle ?? 0.7, 1,
|
||||||
]);
|
]);
|
||||||
await scene.setLight("spot", this.id, true);
|
|
||||||
return this;
|
return this;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -33,7 +32,6 @@ export class SpotLight extends Node {
|
|||||||
await this.ready;
|
await this.ready;
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
this.scene.array("spotLights").row(this.id).fill(0);
|
this.scene.array("spotLights").row(this.id).fill(0);
|
||||||
await this.scene.setLight("spot", this.id, false);
|
|
||||||
await super.dispose();
|
await super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
if (color.length !== 4 || emissive.length !== 3) throw new TypeError("PBR material vectors");
|
||||||
const attenuation = Array.from(options.attenuationColor ?? [1, 1, 1]);
|
scene.array("materials").write(id, [
|
||||||
const secondary = Array.from(
|
...color,
|
||||||
options.flags && options.flags & 16
|
options.metallic ?? 0,
|
||||||
? (options.specularColor ?? [1, 1, 1])
|
options.roughness ?? 0.7,
|
||||||
: (options.sheenColor ?? [0, 0, 0]),
|
...emissive,
|
||||||
);
|
options.normalScale ?? 1,
|
||||||
const specularExtensionColor = Array.from(
|
options.alphaCutoff ?? 0.5,
|
||||||
options.specularExtensionColor ?? [1, 1, 1],
|
0,
|
||||||
);
|
]);
|
||||||
if (
|
scene.array("materialTextures").write(id, [
|
||||||
color.length !== 4 ||
|
(options.baseColorTexture?.id ?? -1) + 1,
|
||||||
emissive.length !== 3 ||
|
(options.metallicRoughnessTexture?.id ?? -1) + 1,
|
||||||
attenuation.length !== 3 ||
|
(options.normalTexture?.id ?? -1) + 1,
|
||||||
secondary.length !== 3 ||
|
(options.emissiveTexture?.id ?? -1) + 1,
|
||||||
specularExtensionColor.length !== 3
|
0, 0, 0, 0,
|
||||||
)
|
]);
|
||||||
throw new TypeError("PBR material vectors");
|
return this;
|
||||||
scene
|
});
|
||||||
.array("materials")
|
|
||||||
.write(id, [
|
|
||||||
...color,
|
|
||||||
options.metallic ?? 0,
|
|
||||||
options.roughness ?? 0.7,
|
|
||||||
...emissive,
|
|
||||||
options.normalScale ?? 1,
|
|
||||||
options.alphaCutoff ?? 0.5,
|
|
||||||
options.flags ?? 0,
|
|
||||||
]);
|
|
||||||
scene
|
|
||||||
.array("materialTextures")
|
|
||||||
.write(id, [
|
|
||||||
(options.baseColorTexture?.id ?? -1) + 1,
|
|
||||||
(options.metallicRoughnessTexture?.id ?? -1) + 1,
|
|
||||||
(options.normalTexture?.id ?? -1) + 1,
|
|
||||||
(options.emissiveTexture?.id ?? -1) + 1,
|
|
||||||
(options.occlusionTexture?.id ?? -1) + 1,
|
|
||||||
(options.transmissionTexture?.id ?? -1) + 1,
|
|
||||||
(options.thicknessTexture?.id ?? -1) + 1,
|
|
||||||
(options.clearcoatTexture?.id ?? -1) + 1,
|
|
||||||
(options.iridescenceTexture?.id ?? -1) + 1,
|
|
||||||
(options.iridescenceThicknessTexture?.id ?? -1) + 1,
|
|
||||||
(options.anisotropyTexture?.id ?? -1) + 1,
|
|
||||||
(options.specularTexture?.id ?? -1) + 1,
|
|
||||||
(options.specularColorTexture?.id ?? -1) + 1,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
]);
|
|
||||||
scene
|
|
||||||
.array("materialExtensions")
|
|
||||||
.write(id, [
|
|
||||||
options.transmission ?? 0,
|
|
||||||
options.ior ?? 1.5,
|
|
||||||
options.thickness ?? 0,
|
|
||||||
options.attenuationDistance ?? 0,
|
|
||||||
...attenuation,
|
|
||||||
options.emissiveStrength ?? 1,
|
|
||||||
options.clearcoat ?? 0,
|
|
||||||
options.clearcoatRoughness ?? 0,
|
|
||||||
options.specular ?? 1,
|
|
||||||
options.occlusionStrength ?? 1,
|
|
||||||
...secondary,
|
|
||||||
options.sheenRoughness ?? 0,
|
|
||||||
options.iridescence ?? 0,
|
|
||||||
options.iridescenceIor ?? 1.3,
|
|
||||||
options.iridescenceThicknessMinimum ?? 100,
|
|
||||||
options.iridescenceThicknessMaximum ?? 400,
|
|
||||||
options.anisotropy ?? 0,
|
|
||||||
options.anisotropyRotation ?? 0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
...specularExtensionColor,
|
|
||||||
0,
|
|
||||||
]);
|
|
||||||
const transforms = new Float32Array(104);
|
|
||||||
for (let slot = 0; slot < 13; slot++) {
|
|
||||||
const info = options.textureInfos?.[slot];
|
|
||||||
const offset = Array.from(info?.offset ?? [0, 0]);
|
|
||||||
const scale = Array.from(info?.scale ?? [1, 1]);
|
|
||||||
if (offset.length !== 2 || scale.length !== 2)
|
|
||||||
throw new TypeError("PBR texture transform");
|
|
||||||
const rotation = info?.rotation ?? 0;
|
|
||||||
transforms.set(
|
|
||||||
[
|
|
||||||
offset[0],
|
|
||||||
offset[1],
|
|
||||||
scale[0],
|
|
||||||
scale[1],
|
|
||||||
Math.sin(rotation),
|
|
||||||
Math.cos(rotation),
|
|
||||||
info?.texCoord ?? 0,
|
|
||||||
0,
|
|
||||||
],
|
|
||||||
slot * 8,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
scene.array("materialTextureInfo").write(id, transforms);
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#values() {
|
#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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,225 +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 features = new Set(["timestamp-query", "indirect-first-instance"]);
|
|
||||||
readonly graphs: any[] = [];
|
|
||||||
readonly serializedGraphs: string[] = [];
|
|
||||||
readonly loadouts: string[] = [];
|
|
||||||
readonly uploadedTextures: { name: string; mipLevel: number }[] = [];
|
|
||||||
fps?: number;
|
|
||||||
disposed = false;
|
|
||||||
#arrays = new Map<string, SharedRows>();
|
|
||||||
#nextObjects = new Map<string, number>();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.#arrays.set(
|
|
||||||
"signals",
|
|
||||||
new SharedRows({ name: "signals", rows: 1, stride: 32, format: "f32" }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
createRows(descriptor: RowDescriptor) {
|
|
||||||
const current = this.#arrays.get(descriptor.name);
|
|
||||||
if (current) {
|
|
||||||
if (
|
|
||||||
current.stride !== descriptor.stride ||
|
|
||||||
current.format !== descriptor.format
|
|
||||||
)
|
|
||||||
throw new Error(`ROW_LAYOUT: ${descriptor.name}`);
|
|
||||||
return Promise.resolve(current.resize(descriptor.rows));
|
|
||||||
}
|
|
||||||
const rows = new SharedRows(descriptor);
|
|
||||||
this.#arrays.set(descriptor.name, rows);
|
|
||||||
return Promise.resolve(rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
createRowsBatch(descriptors: RowDescriptor[]) {
|
|
||||||
return Promise.all(
|
|
||||||
descriptors.map((descriptor) => this.createRows(descriptor)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteRows(name: string) {
|
|
||||||
this.#arrays.delete(name);
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
allocateObject(name: string, count = 1) {
|
|
||||||
const id = this.#nextObjects.get(name) ?? 0;
|
|
||||||
this.#nextObjects.set(name, id + count);
|
|
||||||
const rows = this.array(name);
|
|
||||||
rows.resize(id + count);
|
|
||||||
return Promise.resolve(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteObject() {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
syncRows() {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
array(name: string) {
|
|
||||||
const rows = this.#arrays.get(name);
|
|
||||||
if (!rows) throw new Error(`UNKNOWN_ARRAY: ${name}`);
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
compileGraph(serialized: string) {
|
|
||||||
this.serializedGraphs.push(serialized);
|
|
||||||
this.graphs.push(deserialize(serialized));
|
|
||||||
return Promise.resolve(`loadout-${this.graphs.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
switchLoadout(id: string) {
|
|
||||||
this.loadouts.push(id);
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
setFps(fps: number) {
|
|
||||||
this.fps = fps;
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadTexture(name: string, _image: unknown, mipLevel = 0) {
|
|
||||||
this.uploadedTextures.push({ name, mipLevel });
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteTexture() {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this.disposed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,395 +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 {
|
|
||||||
bucketDraws,
|
|
||||||
createScene,
|
|
||||||
latestGraph,
|
|
||||||
testCore,
|
|
||||||
triangle,
|
|
||||||
} from "./scene-helpers";
|
|
||||||
|
|
||||||
function close(actual: ArrayLike<number>, expected: ArrayLike<number>) {
|
|
||||||
assert.equal(actual.length, expected.length);
|
|
||||||
for (let index = 0; index < actual.length; index++)
|
|
||||||
assert.ok(
|
|
||||||
Math.abs(actual[index] - expected[index]) < 1e-5,
|
|
||||||
`${actual[index]} != ${expected[index]} at ${index}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("PBRMaterial packs factors, extensions, texture pointers, and UV transforms", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const textures = Array.from(
|
|
||||||
{ length: 13 },
|
|
||||||
(_, id) => ({ id, ready: Promise.resolve() }) as unknown as Texture,
|
|
||||||
);
|
|
||||||
const material = new PBRMaterial(scene, {
|
|
||||||
baseColor: [0.1, 0.2, 0.3, 0.4],
|
|
||||||
metallic: 0.5,
|
|
||||||
roughness: 0.6,
|
|
||||||
emissive: [0.7, 0.8, 0.9],
|
|
||||||
normalScale: 0.75,
|
|
||||||
alphaCutoff: 0.25,
|
|
||||||
flags: 6,
|
|
||||||
baseColorTexture: textures[0],
|
|
||||||
metallicRoughnessTexture: textures[1],
|
|
||||||
normalTexture: textures[2],
|
|
||||||
emissiveTexture: textures[3],
|
|
||||||
occlusionTexture: textures[4],
|
|
||||||
transmissionTexture: textures[5],
|
|
||||||
thicknessTexture: textures[6],
|
|
||||||
clearcoatTexture: textures[7],
|
|
||||||
iridescenceTexture: textures[8],
|
|
||||||
iridescenceThicknessTexture: textures[9],
|
|
||||||
anisotropyTexture: textures[10],
|
|
||||||
specularTexture: textures[11],
|
|
||||||
specularColorTexture: textures[12],
|
|
||||||
transmission: 0.8,
|
|
||||||
ior: 1.6,
|
|
||||||
thickness: 0.4,
|
|
||||||
attenuationDistance: 3,
|
|
||||||
attenuationColor: [0.1, 0.2, 0.3],
|
|
||||||
emissiveStrength: 4,
|
|
||||||
clearcoat: 0.5,
|
|
||||||
clearcoatRoughness: 0.6,
|
|
||||||
specular: 0.7,
|
|
||||||
occlusionStrength: 0.8,
|
|
||||||
sheenColor: [0.9, 0.1, 0.2],
|
|
||||||
sheenRoughness: 0.3,
|
|
||||||
iridescence: 0.4,
|
|
||||||
iridescenceIor: 1.4,
|
|
||||||
iridescenceThicknessMinimum: 120,
|
|
||||||
iridescenceThicknessMaximum: 380,
|
|
||||||
anisotropy: 0.5,
|
|
||||||
anisotropyRotation: 0.6,
|
|
||||||
specularExtensionColor: [0.7, 0.8, 0.9],
|
|
||||||
textureInfos: [
|
|
||||||
{
|
|
||||||
offset: [0.1, 0.2],
|
|
||||||
scale: [0.3, 0.4],
|
|
||||||
rotation: 0.5,
|
|
||||||
texCoord: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await material.ready;
|
|
||||||
|
|
||||||
close(
|
|
||||||
scene.array("materials").row(material.id),
|
|
||||||
[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.75, 0.25, 6],
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
Array.from(scene.array("materialTextures").row(material.id)),
|
|
||||||
[...Array.from({ length: 13 }, (_, index) => index + 1), 0, 0, 0],
|
|
||||||
);
|
|
||||||
close(
|
|
||||||
scene.array("materialExtensions").row(material.id),
|
|
||||||
[
|
|
||||||
0.8, 1.6, 0.4, 3, 0.1, 0.2, 0.3, 4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.1, 0.2,
|
|
||||||
0.3, 0.4, 1.4, 120, 380, 0.5, 0.6, 0, 0, 0.7, 0.8, 0.9, 0,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const transforms = scene.array("materialTextureInfo").row(material.id);
|
|
||||||
close(transforms.subarray(0, 8), [
|
|
||||||
0.1,
|
|
||||||
0.2,
|
|
||||||
0.3,
|
|
||||||
0.4,
|
|
||||||
Math.sin(0.5),
|
|
||||||
Math.cos(0.5),
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
]);
|
|
||||||
close(transforms.subarray(8, 16), [0, 0, 1, 1, 0, 1, 0, 0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hot material setters write shared rows without rebuilding the graph", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const material = new PBRMaterial(scene);
|
|
||||||
await material.ready;
|
|
||||||
const core = testCore(scene);
|
|
||||||
const compiled = core.graphs.length;
|
|
||||||
core.array("signals").row(0).fill(0);
|
|
||||||
|
|
||||||
material.baseColor = [0.2, 0.3, 0.4, 0.5];
|
|
||||||
material.metallic = 0.6;
|
|
||||||
material.roughness = 0.7;
|
|
||||||
material.emissive = [0.8, 0.9, 1];
|
|
||||||
|
|
||||||
close(
|
|
||||||
scene.array("materials").row(material.id),
|
|
||||||
[0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1, 0.5, 0],
|
|
||||||
);
|
|
||||||
assert.equal(core.array("signals").row(0)[5], 1);
|
|
||||||
assert.equal(core.graphs.length, compiled);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Mesh packs geometry and bounds, then clones with copy-on-write", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const firstMaterial = new PBRMaterial(scene);
|
|
||||||
const secondMaterial = new PBRMaterial(scene, { baseColor: [1, 0, 0, 1] });
|
|
||||||
await Promise.all([firstMaterial.ready, secondMaterial.ready]);
|
|
||||||
const mesh = new Mesh(scene, {
|
|
||||||
material: firstMaterial,
|
|
||||||
vertexData: triangle,
|
|
||||||
});
|
|
||||||
await mesh.ready;
|
|
||||||
|
|
||||||
assert.equal(mesh.vertexCount, 3);
|
|
||||||
assert.equal(mesh.indexCount, 3);
|
|
||||||
close(scene.geometryData(mesh.geometryId, "positions")!, triangle.positions);
|
|
||||||
close(scene.array("bounds").row(mesh.id), [-1, -1, 0, 0, 1, 1, 0, 0]);
|
|
||||||
// Vertex 1 lives in the shared interleaved array: position, normal, uv, uv2, tangent, colour.
|
|
||||||
const geometry = scene.geometry(mesh.geometryId);
|
|
||||||
close(
|
|
||||||
scene.array("vertices").row(geometry.vertexRow + 1),
|
|
||||||
[1, -1, 0, 0, 0, 0, 1, 0, 1, 0, 0.9, 0.1, 1, 0, 0, 1, 0, 1, 0, 1],
|
|
||||||
);
|
|
||||||
close(scene.array("indices").row(geometry.indexRow), [0, 1, 2, 0]);
|
|
||||||
|
|
||||||
const clone = mesh.clone({ position: [2, 0, 0] });
|
|
||||||
await clone.ready;
|
|
||||||
assert.equal(clone.geometryId, mesh.geometryId);
|
|
||||||
await clone.setVertexData("positions", [0, 0, 0, 2, 0, 0, 0, 3, 0]);
|
|
||||||
assert.notEqual(clone.geometryId, mesh.geometryId);
|
|
||||||
close(scene.geometryData(mesh.geometryId, "positions")!, triangle.positions);
|
|
||||||
close(
|
|
||||||
scene.geometryData(clone.geometryId, "positions")!,
|
|
||||||
[0, 0, 0, 2, 0, 0, 0, 3, 0],
|
|
||||||
);
|
|
||||||
|
|
||||||
clone.material = secondMaterial;
|
|
||||||
await clone.setMaterialForFaces(firstMaterial, 0);
|
|
||||||
assert.equal(clone.materialId, secondMaterial.id);
|
|
||||||
assert.equal(clone.faceMaterials.get(0), firstMaterial.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("geometry spans validate before mutating, move intact, and inhibit rendering until published", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const material = new PBRMaterial(scene);
|
|
||||||
await material.ready;
|
|
||||||
const first = new Mesh(scene, { material, vertexData: triangle });
|
|
||||||
const second = new Mesh(scene, { material, vertexData: triangle });
|
|
||||||
await Promise.all([first.ready, second.ready]);
|
|
||||||
const core = testCore(scene);
|
|
||||||
const before = {
|
|
||||||
graphs: core.graphs.length,
|
|
||||||
first: { ...scene.geometry(first.geometryId) },
|
|
||||||
second: { ...scene.geometry(second.geometryId) },
|
|
||||||
};
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
first.setVertexData("indices", [0, 1, 3]),
|
|
||||||
/VERTEX_DATA: indices\[2\]/u,
|
|
||||||
);
|
|
||||||
await assert.rejects(
|
|
||||||
first.setVertexData("indices", [0, 1, 1.5]),
|
|
||||||
/VERTEX_DATA: indices\[2\]/u,
|
|
||||||
);
|
|
||||||
await assert.rejects(
|
|
||||||
first.setVertexData("normals", [0, 0, 1]),
|
|
||||||
/VERTEX_DATA: normals/u,
|
|
||||||
);
|
|
||||||
assert.deepEqual({ ...scene.geometry(first.geometryId) }, before.first);
|
|
||||||
close(scene.geometryData(first.geometryId, "indices")!, [0, 1, 2]);
|
|
||||||
assert.equal(core.graphs.length, before.graphs);
|
|
||||||
|
|
||||||
// Hold the older loadout switch open while the first mesh grows past its span.
|
|
||||||
let releaseSwitch!: () => void;
|
|
||||||
const switching = new Promise<void>((resolve) => (releaseSwitch = resolve));
|
|
||||||
const switchLoadout = core.switchLoadout.bind(core);
|
|
||||||
core.switchLoadout = (id) => switching.then(() => switchLoadout(id));
|
|
||||||
const older = scene.updateRenderGraph();
|
|
||||||
const grown = first.setVertexData({
|
|
||||||
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0],
|
|
||||||
normals: [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
|
|
||||||
tangents: Array(16).fill(0),
|
|
||||||
uvs: [0, 0, 1, 0, 0, 1, 1, 1],
|
|
||||||
uvs2: [0, 0, 1, 0, 0, 1, 1, 1],
|
|
||||||
colors: Array(16).fill(1),
|
|
||||||
indices: [0, 1, 2, 2, 1, 3],
|
|
||||||
});
|
|
||||||
const signals = core.array("signals").row(0);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve));
|
|
||||||
assert.equal(signals[4], 1);
|
|
||||||
assert.equal(core.loadouts.length, before.graphs);
|
|
||||||
releaseSwitch();
|
|
||||||
await older;
|
|
||||||
assert.equal(signals[4], 1);
|
|
||||||
core.switchLoadout = switchLoadout;
|
|
||||||
await grown;
|
|
||||||
assert.equal(signals[4], 0);
|
|
||||||
assert.equal(signals[5], 1);
|
|
||||||
|
|
||||||
const moved = scene.geometry(first.geometryId);
|
|
||||||
assert.equal(moved.vertexCount, 4);
|
|
||||||
assert.notEqual(moved.vertexRow, before.first.vertexRow);
|
|
||||||
assert.deepEqual({ ...scene.geometry(second.geometryId) }, before.second);
|
|
||||||
close(
|
|
||||||
scene.array("vertices").row(before.second.vertexRow + 1),
|
|
||||||
[1, -1, 0, 0, 0, 0, 1, 0, 1, 0, 0.9, 0.1, 1, 0, 0, 1, 0, 1, 0, 1],
|
|
||||||
);
|
|
||||||
close(scene.array("indices").row(moved.indexRow), [0, 1, 2, 2]);
|
|
||||||
const draws = bucketDraws(scene, latestGraph(scene));
|
|
||||||
assert.deepEqual(
|
|
||||||
draws.map((draw) => [draw.baseVertex, draw.firstIndex, draw.indices]),
|
|
||||||
[
|
|
||||||
[moved.vertexRow, moved.indexRow * 4, 6],
|
|
||||||
[before.second.vertexRow, before.second.indexRow * 4, 3],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Node, ArcRotateCamera, and light handles update their shared rows", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const target = new Node(scene, { position: [1, 2, 3], scale: [2, 3, 4] });
|
|
||||||
await target.ready;
|
|
||||||
target.translate([1, -1, 2]).rotateZ(Math.PI);
|
|
||||||
close(target.position, [2, 1, 5]);
|
|
||||||
close(target.scale, [2, 3, 4]);
|
|
||||||
close(target.rotor, [0, 0, 1, 0]);
|
|
||||||
|
|
||||||
const child = new Node(scene, { parent: target });
|
|
||||||
await child.ready;
|
|
||||||
assert.equal(child.parentId, target.id);
|
|
||||||
child.enabled = false;
|
|
||||||
assert.equal(scene.array("nodes").row(child.id)[0], 0);
|
|
||||||
|
|
||||||
const camera = new ArcRotateCamera(scene, {
|
|
||||||
target,
|
|
||||||
alpha: 0,
|
|
||||||
beta: Math.PI / 2,
|
|
||||||
radius: 5,
|
|
||||||
});
|
|
||||||
await camera.ready;
|
|
||||||
close(camera.position, [2, 1, 10]);
|
|
||||||
assert.equal(scene.array("cameraMatrices").row(camera.cameraId)[19], 1);
|
|
||||||
camera.radius = 3;
|
|
||||||
close(camera.position, [2, 1, 8]);
|
|
||||||
|
|
||||||
const light = new PointLight(scene, {
|
|
||||||
position: [4, 5, 6],
|
|
||||||
color: [0.2, 0.4, 0.6],
|
|
||||||
intensity: 7,
|
|
||||||
range: 8,
|
|
||||||
radius: 2,
|
|
||||||
});
|
|
||||||
await light.ready;
|
|
||||||
close(
|
|
||||||
scene.array("pointLights").row(light.id),
|
|
||||||
[0.2, 0.4, 0.6, 7, 8, 1, 2, 0],
|
|
||||||
);
|
|
||||||
light.intensity = 9;
|
|
||||||
light.range = 10;
|
|
||||||
light.radius = 3;
|
|
||||||
close(scene.array("pointLights").row(light.id).subarray(3, 5), [9, 10]);
|
|
||||||
assert.equal(scene.array("pointLights").row(light.id)[6], 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ArcRotateCamera damps pan, orbit and zoom with reversed vertical orbit", async (t) => {
|
|
||||||
let now = 0;
|
|
||||||
let next = 1;
|
|
||||||
const frames = new Map<number, FrameRequestCallback>();
|
|
||||||
const originalRequest = globalThis.requestAnimationFrame;
|
|
||||||
const originalCancel = globalThis.cancelAnimationFrame;
|
|
||||||
t.mock.method(performance, "now", () => now);
|
|
||||||
globalThis.requestAnimationFrame = (callback) => {
|
|
||||||
const id = next++;
|
|
||||||
frames.set(id, callback);
|
|
||||||
return id;
|
|
||||||
};
|
|
||||||
globalThis.cancelAnimationFrame = (id) => {
|
|
||||||
frames.delete(id);
|
|
||||||
};
|
|
||||||
t.after(() => {
|
|
||||||
globalThis.requestAnimationFrame = originalRequest;
|
|
||||||
globalThis.cancelAnimationFrame = originalCancel;
|
|
||||||
});
|
|
||||||
const advance = (ms: number) => {
|
|
||||||
now += ms;
|
|
||||||
const callbacks = [...frames.values()];
|
|
||||||
frames.clear();
|
|
||||||
for (const callback of callbacks) callback(now);
|
|
||||||
};
|
|
||||||
const listeners = new Map<string, Set<(event: any) => void>>();
|
|
||||||
const element = {
|
|
||||||
clientWidth: 400,
|
|
||||||
clientHeight: 200,
|
|
||||||
addEventListener(type: string, listener: (event: any) => void) {
|
|
||||||
const registered = listeners.get(type) ?? new Set();
|
|
||||||
registered.add(listener);
|
|
||||||
listeners.set(type, registered);
|
|
||||||
},
|
|
||||||
removeEventListener(type: string, listener: (event: any) => void) {
|
|
||||||
listeners.get(type)?.delete(listener);
|
|
||||||
},
|
|
||||||
setPointerCapture() {},
|
|
||||||
} as unknown as HTMLElement;
|
|
||||||
const dispatch = (type: string, event: Record<string, unknown>) => {
|
|
||||||
for (const listener of listeners.get(type) ?? [])
|
|
||||||
listener({ preventDefault() {}, ...event });
|
|
||||||
};
|
|
||||||
const scene = await createScene();
|
|
||||||
const camera = new ArcRotateCamera(scene, {
|
|
||||||
alpha: 0,
|
|
||||||
beta: Math.PI / 2,
|
|
||||||
radius: 10,
|
|
||||||
fov: Math.PI / 2,
|
|
||||||
aspect: 2,
|
|
||||||
controls: { element },
|
|
||||||
});
|
|
||||||
await camera.ready;
|
|
||||||
|
|
||||||
dispatch("pointerdown", {
|
|
||||||
button: 2,
|
|
||||||
clientX: 100,
|
|
||||||
clientY: 100,
|
|
||||||
pointerId: 1,
|
|
||||||
});
|
|
||||||
dispatch("pointermove", { clientX: 120, clientY: 110 });
|
|
||||||
dispatch("pointerup", {});
|
|
||||||
advance(80);
|
|
||||||
close(camera.position, [-2 * (1 - Math.exp(-1)), 1 - Math.exp(-1), 10]);
|
|
||||||
advance(1600);
|
|
||||||
close(camera.position, [-2, 1, 10]);
|
|
||||||
advance(16);
|
|
||||||
assert.equal(frames.size, 0);
|
|
||||||
const orbit = () => {
|
|
||||||
dispatch("pointerdown", {
|
|
||||||
button: 0,
|
|
||||||
clientX: 100,
|
|
||||||
clientY: 100,
|
|
||||||
pointerId: 1,
|
|
||||||
});
|
|
||||||
dispatch("pointermove", { clientX: 140, clientY: 120 });
|
|
||||||
dispatch("pointerup", {});
|
|
||||||
};
|
|
||||||
orbit();
|
|
||||||
advance(40);
|
|
||||||
const released = camera.alpha;
|
|
||||||
advance(40);
|
|
||||||
assert.ok(camera.alpha < released);
|
|
||||||
const pose = [camera.alpha, camera.beta];
|
|
||||||
close(pose, [
|
|
||||||
-0.2 * (1 - Math.exp(-1)),
|
|
||||||
Math.PI / 2 - 0.1 * (1 - Math.exp(-1)),
|
|
||||||
]);
|
|
||||||
camera.detachControls();
|
|
||||||
camera.alpha = 0;
|
|
||||||
camera.beta = Math.PI / 2;
|
|
||||||
camera.attachControls({ element });
|
|
||||||
orbit();
|
|
||||||
advance(80);
|
|
||||||
close([camera.alpha, camera.beta], pose);
|
|
||||||
dispatch("wheel", { deltaY: 100 });
|
|
||||||
advance(80);
|
|
||||||
close([camera.radius], [10 * Math.exp(0.1 * (1 - Math.exp(-1)))]);
|
|
||||||
camera.detachControls();
|
|
||||||
assert.equal(frames.size, 0);
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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, []);
|
|
||||||
});
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -1,611 +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 { ShaderMaterial } from "../src/materials/ShaderMaterial";
|
|
||||||
import { PointLight } from "../src/lights/PointLight";
|
|
||||||
import { SpotLight } from "../src/lights/SpotLight";
|
|
||||||
import { DirectionalLight } from "../src/lights/DirectionalLight";
|
|
||||||
import { ComputePass } from "../src/ComputePass";
|
|
||||||
import {
|
|
||||||
bucketDraws,
|
|
||||||
createScene,
|
|
||||||
detailedMesh,
|
|
||||||
forwardPasses,
|
|
||||||
latestGraph,
|
|
||||||
pbrPipelines,
|
|
||||||
renderPipeline,
|
|
||||||
testCore,
|
|
||||||
triangle,
|
|
||||||
} from "./scene-helpers";
|
|
||||||
|
|
||||||
if (typeof globalThis.ImageBitmap === "undefined")
|
|
||||||
Object.defineProperty(globalThis, "ImageBitmap", {
|
|
||||||
value: class ImageBitmap {},
|
|
||||||
});
|
|
||||||
|
|
||||||
test("empty scenes use neutral authored defaults without placeholder geometry", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const emptyPass = latestGraph(scene).passes.find(
|
|
||||||
(pass: any) => pass.id === "forward-empty",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(emptyPass.draw.vertices, 0);
|
|
||||||
assert.deepEqual(emptyPass.color[0].clear, [0, 0, 0, 1]);
|
|
||||||
assert.deepEqual(Array.from(scene.array("sceneAccent").row(0)), [1, 1, 1, 1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opaque PBR graph has no implicit environment and compacts sparse lights", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
await Promise.all([
|
|
||||||
new PointLight(scene).ready,
|
|
||||||
new SpotLight(scene).ready,
|
|
||||||
new DirectionalLight(scene).ready,
|
|
||||||
]);
|
|
||||||
const material = new PBRMaterial(scene);
|
|
||||||
await material.ready;
|
|
||||||
const mesh = detailedMesh(scene, material);
|
|
||||||
await mesh.ready;
|
|
||||||
|
|
||||||
const graph = latestGraph(scene);
|
|
||||||
const pipeline = pbrPipelines(graph)[0];
|
|
||||||
const forward = forwardPasses(graph)[0];
|
|
||||||
const present = renderPipeline(graph, "present");
|
|
||||||
const clusters = graph.pipelines.compute.find(
|
|
||||||
(candidate: any) => candidate.id === "cluster-lights",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(pipeline.primitive.frontFace, "ccw");
|
|
||||||
assert.equal(
|
|
||||||
graph.passes.some((pass: any) => pass.id.includes("background")),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.doesNotMatch(JSON.stringify(graph), /studio/iu);
|
|
||||||
assert.deepEqual(forward.color[0].clear, [0, 0, 0, 1]);
|
|
||||||
assert.match(pipeline.code, /@interpolate\(flat\) material: u32/u);
|
|
||||||
assert.match(pipeline.code, /materials\[input\.material \* 3u\]/u);
|
|
||||||
assert.doesNotMatch(pipeline.code, /defaultLight|studioEnvironment/u);
|
|
||||||
assert.doesNotMatch(pipeline.code, /0\.4, 0\.7, 0\.6/u);
|
|
||||||
assert.match(
|
|
||||||
pipeline.code,
|
|
||||||
/let ambient = vec3<f32>\(bitcast<f32>\(clusters\[1012\]\)/u,
|
|
||||||
);
|
|
||||||
assert.match(pipeline.code, /fn directContribution/u);
|
|
||||||
assert.match(pipeline.code, /fn directionalContribution/u);
|
|
||||||
assert.equal(
|
|
||||||
forward.bindings.find((binding: any) => binding.binding === 10)?.resource,
|
|
||||||
"directional-lights",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(clusters.code, /clusters\[11u \+ pointCount\] = i/u);
|
|
||||||
assert.match(clusters.code, /clusters\[512u \+ spotCount\] = i/u);
|
|
||||||
assert.match(pipeline.code, /let pointCount = clusters\[10\]/u);
|
|
||||||
assert.match(pipeline.code, /pointCount <= 500u/u);
|
|
||||||
assert.match(pipeline.code, /arrayLength\(&pointLights\) \/ 2u/u);
|
|
||||||
assert.match(
|
|
||||||
pipeline.code,
|
|
||||||
/let sourceRoughness = clamp\(roughness \+ max\(state\.z/u,
|
|
||||||
);
|
|
||||||
assert.match(pipeline.code, /let spotCount = clusters\[511\]/u);
|
|
||||||
assert.match(pipeline.code, /spotCount <= 500u/u);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("materials with identical shader features share one pipeline", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const firstMaterial = new PBRMaterial(scene, { roughness: 0.2 });
|
|
||||||
const secondMaterial = new PBRMaterial(scene, { roughness: 0.8 });
|
|
||||||
await Promise.all([firstMaterial.ready, secondMaterial.ready]);
|
|
||||||
const firstMesh = detailedMesh(scene, firstMaterial);
|
|
||||||
const secondMesh = detailedMesh(scene, secondMaterial);
|
|
||||||
await Promise.all([firstMesh.ready, secondMesh.ready]);
|
|
||||||
|
|
||||||
const graph = latestGraph(scene);
|
|
||||||
const draws = bucketDraws(scene, graph);
|
|
||||||
assert.equal(pbrPipelines(graph).length, 1);
|
|
||||||
// One indirect bucket pass holds both meshes: same pipeline and bind set.
|
|
||||||
assert.equal(forwardPasses(graph).length, 1);
|
|
||||||
assert.deepEqual(
|
|
||||||
draws.map((draw) => draw.firstInstance & 0xffff),
|
|
||||||
[firstMesh.id, secondMesh.id],
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
draws.map((draw) => (draw.firstInstance >>> 16) - 1),
|
|
||||||
[firstMaterial.id, secondMaterial.id],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("transmission renders directly into a composited layer with five taps", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const material = new PBRMaterial(scene, {
|
|
||||||
transmission: 0.9,
|
|
||||||
thickness: 0.25,
|
|
||||||
attenuationDistance: 2,
|
|
||||||
});
|
|
||||||
await material.ready;
|
|
||||||
const mesh = detailedMesh(scene, material);
|
|
||||||
await mesh.ready;
|
|
||||||
|
|
||||||
const graph = latestGraph(scene);
|
|
||||||
const pipeline = pbrPipelines(graph)[0];
|
|
||||||
const present = renderPipeline(graph, "present");
|
|
||||||
const presentPass = graph.passes.find((pass: any) => pass.id === "present");
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
graph.passes.some((pass: any) => pass.id === "scene-color-clear"),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const sceneColorClear = graph.passes.find(
|
|
||||||
(pass: any) => pass.id === "scene-color-clear",
|
|
||||||
);
|
|
||||||
assert.equal(sceneColorClear.draw.vertices, 0);
|
|
||||||
assert.deepEqual(sceneColorClear.color[0].clear, [0, 0, 0, 1]);
|
|
||||||
assert.equal(
|
|
||||||
graph.passes.some((pass: any) => pass.id === "copy-scene-color"),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
presentPass.bindings.some(
|
|
||||||
(binding: any) => binding.resource === "scene-color",
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.match(present.code, /textureSample\(scene, sourceSampler/u);
|
|
||||||
assert.equal(
|
|
||||||
pipeline.code.match(/textureSampleLevel\(sceneColor/g)?.length,
|
|
||||||
5,
|
|
||||||
);
|
|
||||||
assert.match(pipeline.code, /transmitted \*= pow/u);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clear color is explicit scene data used by empty and forward graphs", async () => {
|
|
||||||
const clearColor = [0.03, 0.06, 0.09, 1] as const;
|
|
||||||
const scene = await createScene({ clearColor });
|
|
||||||
const empty = latestGraph(scene).passes.find(
|
|
||||||
(pass: any) => pass.id === "forward-empty",
|
|
||||||
);
|
|
||||||
assert.deepEqual(empty.color[0].clear, clearColor);
|
|
||||||
|
|
||||||
const material = new PBRMaterial(scene);
|
|
||||||
await material.ready;
|
|
||||||
const mesh = detailedMesh(scene, material);
|
|
||||||
await mesh.ready;
|
|
||||||
|
|
||||||
const graph = latestGraph(scene);
|
|
||||||
assert.deepEqual(forwardPasses(graph)[0].color[0].clear, clearColor);
|
|
||||||
assert.deepEqual(
|
|
||||||
graph.passes.find((pass: any) => pass.id === "present").color[0].clear,
|
|
||||||
clearColor,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("post-processing keeps the conservative transmission copy", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const material = new PBRMaterial(scene, { transmission: 1 });
|
|
||||||
await material.ready;
|
|
||||||
const mesh = detailedMesh(scene, material);
|
|
||||||
await mesh.ready;
|
|
||||||
const effect = new FXAA(scene);
|
|
||||||
await effect.ready;
|
|
||||||
|
|
||||||
const graph = latestGraph(scene);
|
|
||||||
const presentPass = graph.passes.find((pass: any) => pass.id === "present");
|
|
||||||
assert.equal(
|
|
||||||
graph.passes.some((pass: any) => pass.id === "copy-scene-color"),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
presentPass.bindings.some(
|
|
||||||
(binding: any) => binding.resource === "scene-color",
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
graph.passes.some((pass: any) => pass.pipeline.startsWith("post-fxaa-")),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("PBR generation eliminates inactive features and specializes active ones", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const textures = Array.from(
|
|
||||||
{ length: 13 },
|
|
||||||
() => new Texture(scene, { size: [1, 1, 1] }),
|
|
||||||
);
|
|
||||||
await Promise.all(textures.map((texture) => texture.ready));
|
|
||||||
const textureOptions = {
|
|
||||||
baseColorTexture: textures[0],
|
|
||||||
metallicRoughnessTexture: textures[1],
|
|
||||||
normalTexture: textures[2],
|
|
||||||
emissiveTexture: textures[3],
|
|
||||||
occlusionTexture: textures[4],
|
|
||||||
transmissionTexture: textures[5],
|
|
||||||
thicknessTexture: textures[6],
|
|
||||||
clearcoatTexture: textures[7],
|
|
||||||
iridescenceTexture: textures[8],
|
|
||||||
iridescenceThicknessTexture: textures[9],
|
|
||||||
anisotropyTexture: textures[10],
|
|
||||||
specularTexture: textures[11],
|
|
||||||
specularColorTexture: textures[12],
|
|
||||||
};
|
|
||||||
const active = new PBRMaterial(scene, {
|
|
||||||
...textureOptions,
|
|
||||||
transmission: 1,
|
|
||||||
thickness: 0.5,
|
|
||||||
attenuationDistance: 2,
|
|
||||||
clearcoat: 0.7,
|
|
||||||
sheenColor: [0.2, 0.3, 0.4],
|
|
||||||
iridescence: 0.8,
|
|
||||||
anisotropy: 0.6,
|
|
||||||
textureInfos: Array.from({ length: 13 }, (_, slot) => ({
|
|
||||||
texCoord: slot === 1 ? 1 : 0,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
const unlit = new PBRMaterial(scene, { ...textureOptions, flags: 8 });
|
|
||||||
await Promise.all([active.ready, unlit.ready]);
|
|
||||||
const activeMesh = detailedMesh(scene, active);
|
|
||||||
const unlitMesh = detailedMesh(scene, unlit);
|
|
||||||
await Promise.all([activeMesh.ready, unlitMesh.ready]);
|
|
||||||
|
|
||||||
const pipelines = pbrPipelines(latestGraph(scene));
|
|
||||||
const activeCode = pipelines.find((pipeline: any) =>
|
|
||||||
pipeline.code.includes("transmissionAmount"),
|
|
||||||
).code;
|
|
||||||
const unlitCode = pipelines.find((pipeline: any) =>
|
|
||||||
pipeline.code.includes("color = base.rgb + emissive"),
|
|
||||||
).code;
|
|
||||||
|
|
||||||
for (const binding of [
|
|
||||||
"baseTexture",
|
|
||||||
"materialTexture",
|
|
||||||
"normalTexture",
|
|
||||||
"emissiveTexture",
|
|
||||||
"occlusionTexture",
|
|
||||||
"transmissionTexture",
|
|
||||||
"thicknessTexture",
|
|
||||||
"clearcoatTexture",
|
|
||||||
"iridescenceTexture",
|
|
||||||
"iridescenceThicknessTexture",
|
|
||||||
"anisotropyTexture",
|
|
||||||
"specularTexture",
|
|
||||||
"specularColorTexture",
|
|
||||||
])
|
|
||||||
assert.match(activeCode, new RegExp(`var ${binding}: texture_2d`));
|
|
||||||
assert.match(
|
|
||||||
activeCode,
|
|
||||||
/textureSample\(baseTexture, materialSampler, input\.uv\)/u,
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
activeCode,
|
|
||||||
/textureSample\(materialTexture, materialSampler, input\.uv2\)/u,
|
|
||||||
);
|
|
||||||
assert.match(activeCode, /let anisotropyAmount/u);
|
|
||||||
assert.match(activeCode, /let iridescenceAmount/u);
|
|
||||||
assert.match(activeCode, /let clearcoatFresnel/u);
|
|
||||||
assert.match(activeCode, /let sheen =/u);
|
|
||||||
assert.match(unlitCode, /var baseTexture: texture_2d/u);
|
|
||||||
assert.match(unlitCode, /var emissiveTexture: texture_2d/u);
|
|
||||||
assert.doesNotMatch(unlitCode, /var normalTexture: texture_2d/u);
|
|
||||||
assert.doesNotMatch(unlitCode, /var clearcoatTexture: texture_2d/u);
|
|
||||||
assert.doesNotMatch(unlitCode, /let clearcoatFresnel/u);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("alpha blending uses source-over and disables depth writes", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const material = new PBRMaterial(scene, { flags: 2 });
|
|
||||||
await material.ready;
|
|
||||||
const mesh = detailedMesh(scene, material);
|
|
||||||
await mesh.ready;
|
|
||||||
|
|
||||||
const pipeline = pbrPipelines(latestGraph(scene))[0];
|
|
||||||
assert.deepEqual(pipeline.fragment.targets[0].blend, {
|
|
||||||
color: {
|
|
||||||
srcFactor: "src-alpha",
|
|
||||||
dstFactor: "one-minus-src-alpha",
|
|
||||||
operation: "add",
|
|
||||||
},
|
|
||||||
alpha: {
|
|
||||||
srcFactor: "one",
|
|
||||||
dstFactor: "one-minus-src-alpha",
|
|
||||||
operation: "add",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert.equal(pipeline.depthStencil.depth_write_enabled, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("batchGraphUpdates compiles once for multiple mesh registrations", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const material = new PBRMaterial(scene);
|
|
||||||
await material.ready;
|
|
||||||
const before = testCore(scene).graphs.length;
|
|
||||||
|
|
||||||
await scene.batchGraphUpdates(async () => {
|
|
||||||
const first = new Mesh(scene, { material, vertexData: triangle });
|
|
||||||
const second = new Mesh(scene, { material, vertexData: triangle });
|
|
||||||
await Promise.all([first.ready, second.ready]);
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(testCore(scene).graphs.length - before, 1);
|
|
||||||
assert.equal(forwardPasses(latestGraph(scene)).length, 1);
|
|
||||||
assert.equal(bucketDraws(scene, latestGraph(scene)).length, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("draw buckets persist per bind set, zero their inactive tail, and stay blocked while compiling", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const core = testCore(scene);
|
|
||||||
const plain = new PBRMaterial(scene);
|
|
||||||
const texture = new Texture(scene, { size: [1, 1, 1] });
|
|
||||||
await texture.ready;
|
|
||||||
const textured = new PBRMaterial(scene, { baseColorTexture: texture });
|
|
||||||
await Promise.all([plain.ready, textured.ready]);
|
|
||||||
const meshes: Mesh[] = [];
|
|
||||||
for (let index = 0; index < 3; index++)
|
|
||||||
meshes.push(await detailedMesh(scene, plain).ready);
|
|
||||||
|
|
||||||
// Three plain meshes fill one bucket; the array grew geometrically from 1 → 2 → 4 rows.
|
|
||||||
let graph = latestGraph(scene);
|
|
||||||
const bucket = graph.resources.buffers.find((buffer: any) =>
|
|
||||||
buffer.id.startsWith("draws-"),
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
{ usage: bucket.usage, sync: bucket.sync },
|
|
||||||
{ usage: ["indirect"], sync: "manual" },
|
|
||||||
);
|
|
||||||
assert.equal(core.array(bucket.array).rows, 4);
|
|
||||||
assert.equal(bucketDraws(scene, graph).length, 3);
|
|
||||||
assert.equal(forwardPasses(graph).length, 1);
|
|
||||||
|
|
||||||
// Moving one mesh to a textured material creates a second bucket; the first keeps two rows
|
|
||||||
// and its freed tail is zero (instanceCount 0) so the GPU skips it.
|
|
||||||
meshes[1].material = textured;
|
|
||||||
await meshes[1].ready;
|
|
||||||
await scene.updateRenderGraph();
|
|
||||||
graph = latestGraph(scene);
|
|
||||||
assert.equal(forwardPasses(graph).length, 2);
|
|
||||||
const draws = bucketDraws(scene, graph);
|
|
||||||
assert.deepEqual(
|
|
||||||
draws.map((draw) => [draw.pass, draw.firstInstance & 0xffff]),
|
|
||||||
[
|
|
||||||
["forward-bucket-0", meshes[0].id],
|
|
||||||
["forward-bucket-0", meshes[2].id],
|
|
||||||
["forward-bucket-1", meshes[1].id],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
[...core.array(bucket.array).view.subarray(16, 32)],
|
|
||||||
Array(16).fill(0),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Back to plain: the second bucket vanishes from the graph, the first regains its row.
|
|
||||||
meshes[1].material = plain;
|
|
||||||
await meshes[1].ready;
|
|
||||||
await scene.updateRenderGraph();
|
|
||||||
graph = latestGraph(scene);
|
|
||||||
assert.equal(forwardPasses(graph).length, 1);
|
|
||||||
assert.equal(bucketDraws(scene, graph).length, 3);
|
|
||||||
|
|
||||||
// A compile that starts after an earlier switch cleared signal 6 re-asserts it before
|
|
||||||
// touching bucket rows, so no frame renders the old graph with new bucket contents.
|
|
||||||
const signals = core.array("signals").row(0);
|
|
||||||
const switchLoadout = core.switchLoadout.bind(core);
|
|
||||||
let observed = -1;
|
|
||||||
core.switchLoadout = async (id) => {
|
|
||||||
await switchLoadout(id);
|
|
||||||
signals[6] = 0;
|
|
||||||
core.switchLoadout = async (next) => {
|
|
||||||
observed = signals[6];
|
|
||||||
await switchLoadout(next);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
await Promise.all([scene.updateRenderGraph(), scene.updateRenderGraph()]);
|
|
||||||
assert.equal(observed, 1);
|
|
||||||
core.switchLoadout = switchLoadout;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opaque buckets render into the composited layer ahead of transmission", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const opaque = new PBRMaterial(scene);
|
|
||||||
const glass = new PBRMaterial(scene, { transmission: 0.9 });
|
|
||||||
await Promise.all([opaque.ready, glass.ready]);
|
|
||||||
const solid = detailedMesh(scene, opaque);
|
|
||||||
const window = detailedMesh(scene, glass);
|
|
||||||
await Promise.all([solid.ready, window.ready]);
|
|
||||||
|
|
||||||
const passes = forwardPasses(latestGraph(scene));
|
|
||||||
assert.deepEqual(
|
|
||||||
passes.map((pass: any) => [
|
|
||||||
pass.id,
|
|
||||||
pass.color[0].resource,
|
|
||||||
Boolean(pass.indirect),
|
|
||||||
]),
|
|
||||||
[
|
|
||||||
["forward-bucket-0", "scene-color", true],
|
|
||||||
[
|
|
||||||
`forward-${window.id}-${scene.geometry(window.geometryId).indexRow * 4}`,
|
|
||||||
"hdr",
|
|
||||||
false,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("render graph snapshots expose isolated scene-owned declarations", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const source: any = await scene.getRenderGraph();
|
|
||||||
|
|
||||||
assert.equal(source.id, "scene");
|
|
||||||
assert.ok(
|
|
||||||
source.resources.buffers.some((buffer: any) => buffer.id === "clusters"),
|
|
||||||
);
|
|
||||||
source.id = "mutated";
|
|
||||||
source.resources.buffers.length = 0;
|
|
||||||
|
|
||||||
const next: any = await scene.getRenderGraph();
|
|
||||||
assert.equal(next.id, "scene");
|
|
||||||
assert.ok(
|
|
||||||
next.resources.buffers.some((buffer: any) => buffer.id === "clusters"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("serialized render graphs compile into explicitly selectable loadouts", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const core = testCore(scene);
|
|
||||||
const serialized = core.serializedGraphs.at(-1)!;
|
|
||||||
const before = core.graphs.length;
|
|
||||||
|
|
||||||
const loadoutId = await scene.compileRenderGraph(serialized);
|
|
||||||
await scene.setLoadout(loadoutId);
|
|
||||||
|
|
||||||
assert.equal(core.graphs.length, before + 1);
|
|
||||||
assert.equal(loadoutId, `loadout-${before + 1}`);
|
|
||||||
assert.equal(core.loadouts.at(-1), loadoutId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("packed ORM reads are shared only for the same texture and UV transform", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const orm = new Texture(scene, { size: [1, 1, 1] });
|
|
||||||
const other = new Texture(scene, { size: [1, 1, 1] });
|
|
||||||
await Promise.all([orm.ready, other.ready]);
|
|
||||||
for (const [occlusion, info, shared] of [
|
|
||||||
[orm, {}, true],
|
|
||||||
[other, {}, false],
|
|
||||||
[orm, { texCoord: 1 }, false],
|
|
||||||
[orm, { offset: [0.25, -0.1] }, false],
|
|
||||||
[orm, { scale: [2, 1] }, false],
|
|
||||||
[orm, { rotation: 0.4 }, false],
|
|
||||||
] as const) {
|
|
||||||
const material = new PBRMaterial(scene, {
|
|
||||||
metallicRoughnessTexture: orm,
|
|
||||||
occlusionTexture: occlusion,
|
|
||||||
textureInfos: [{}, {}, {}, {}, info],
|
|
||||||
});
|
|
||||||
await material.ready;
|
|
||||||
const mesh = await detailedMesh(scene, material).ready;
|
|
||||||
const graph = latestGraph(scene);
|
|
||||||
const pass = forwardPasses(graph).at(-1);
|
|
||||||
const code = renderPipeline(graph, pass.pipeline).code;
|
|
||||||
assert.equal(code.includes("mix(1.0, packedMaterial.r"), shared);
|
|
||||||
assert.equal(code.includes("textureSample(occlusionTexture"), !shared);
|
|
||||||
assert.equal(
|
|
||||||
pass.bindings.some((b: any) => b.group === 1 && b.binding === 5),
|
|
||||||
!shared,
|
|
||||||
);
|
|
||||||
await mesh.dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("light variants follow first/last light lifetimes and keep hot edits rebuild-free", async () => {
|
|
||||||
const scene = await createScene();
|
|
||||||
const material = await new PBRMaterial(scene).ready;
|
|
||||||
await detailedMesh(scene, material).ready;
|
|
||||||
const code = () => pbrPipelines(latestGraph(scene))[0].code;
|
|
||||||
assert.doesNotMatch(code(), /fn (point|spot|directional)Contribution/);
|
|
||||||
const first = await new PointLight(scene, { intensity: 0 }).ready;
|
|
||||||
assert.match(code(), /fn pointContribution/);
|
|
||||||
assert.doesNotMatch(code(), /fn (spot|directional)Contribution/);
|
|
||||||
assert.equal(code().match(/let nDotV =/g)?.length, 1);
|
|
||||||
assert.match(code(), /let diffuseColor = base.rgb \* \(1.0 - metallic\)/);
|
|
||||||
const before = testCore(scene).graphs.length;
|
|
||||||
first.intensity = 3;
|
|
||||||
first.radius = 0.4;
|
|
||||||
const second = await new PointLight(scene).ready;
|
|
||||||
await first.dispose();
|
|
||||||
assert.equal(testCore(scene).graphs.length, before);
|
|
||||||
assert.match(code(), /fn pointContribution/);
|
|
||||||
const spot = await new SpotLight(scene).ready;
|
|
||||||
const directional = await new DirectionalLight(scene).ready;
|
|
||||||
assert.match(code(), /fn spotContribution/);
|
|
||||||
assert.match(code(), /fn directionalContribution/);
|
|
||||||
await second.dispose();
|
|
||||||
assert.doesNotMatch(code(), /fn pointContribution/);
|
|
||||||
await spot.dispose();
|
|
||||||
await directional.dispose();
|
|
||||||
assert.doesNotMatch(code(), /fn (point|spot|directional)Contribution/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("direct canvas loadout preserves clear/depth and falls back for linear-color consumers", async () => {
|
|
||||||
const scene = await createScene({
|
|
||||||
directToCanvas: true,
|
|
||||||
clearColor: [0.02, 0.09, 0.3, 0.5],
|
|
||||||
});
|
|
||||||
const material = await new PBRMaterial(scene).ready;
|
|
||||||
const mesh = await detailedMesh(scene, material).ready;
|
|
||||||
const assertDirect = () => {
|
|
||||||
const graph = latestGraph(scene);
|
|
||||||
assert.equal(
|
|
||||||
graph.resources.textures.some((t: any) => t.id === "hdr"),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
graph.passes.some((p: any) => p.id === "present"),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
const background = graph.passes.find(
|
|
||||||
(p: any) => p.id === "canvas-background",
|
|
||||||
);
|
|
||||||
assert.deepEqual(background.depth, { resource: "depth", clear: 1 });
|
|
||||||
assert.match(
|
|
||||||
renderPipeline(graph, "canvas-background").code,
|
|
||||||
/canvasOutput\(vec4<f32>\(0.02, 0.09, 0.3, 0.5\)\)/,
|
|
||||||
);
|
|
||||||
const forward = forwardPasses(graph)[0];
|
|
||||||
assert.deepEqual(forward.color, [{ resource: "canvas", load: "load" }]);
|
|
||||||
assert.deepEqual(forward.depth, { resource: "depth", load: "load" });
|
|
||||||
assert.equal(pbrPipelines(graph)[0].fragment.targets[0].format, "canvas");
|
|
||||||
assert.match(pbrPipelines(graph)[0].code, /unpack2x16float\(pack2x16float/);
|
|
||||||
};
|
|
||||||
const assertHdr = () =>
|
|
||||||
assert.ok(renderPipeline(latestGraph(scene), "present"));
|
|
||||||
assertDirect();
|
|
||||||
const effect = new FXAA(scene);
|
|
||||||
await effect.ready;
|
|
||||||
assertHdr();
|
|
||||||
await effect.dispose();
|
|
||||||
assertDirect();
|
|
||||||
for (const options of [{ flags: 2 }, { transmission: 0.5 }]) {
|
|
||||||
const special = await new PBRMaterial(scene, options).ready;
|
|
||||||
const extra = await detailedMesh(scene, special).ready;
|
|
||||||
assertHdr();
|
|
||||||
await extra.dispose();
|
|
||||||
assertDirect();
|
|
||||||
}
|
|
||||||
const compute = new ComputePass({
|
|
||||||
code: "@compute @workgroup_size(1) fn main() {}",
|
|
||||||
});
|
|
||||||
await scene.addComputePass(compute);
|
|
||||||
assertHdr();
|
|
||||||
assert.match(
|
|
||||||
pbrPipelines(latestGraph(scene))[0].code,
|
|
||||||
/fn directionalContribution/,
|
|
||||||
);
|
|
||||||
await scene.removeComputePass(compute.id);
|
|
||||||
assertDirect();
|
|
||||||
const custom = new ShaderMaterial(scene, { code: "custom WGSL" });
|
|
||||||
await custom.ready;
|
|
||||||
assertHdr();
|
|
||||||
await custom.dispose();
|
|
||||||
assertDirect();
|
|
||||||
const basic = await new Mesh(scene, {
|
|
||||||
vertexData: { positions: triangle.positions, indices: triangle.indices },
|
|
||||||
}).ready;
|
|
||||||
assertHdr();
|
|
||||||
await basic.dispose();
|
|
||||||
assertDirect();
|
|
||||||
const mask = await new PBRMaterial(scene, { flags: 1 }).ready;
|
|
||||||
const masked = await detailedMesh(scene, mask).ready;
|
|
||||||
assertDirect();
|
|
||||||
assert.match(
|
|
||||||
pbrPipelines(latestGraph(scene)).find((p: any) =>
|
|
||||||
p.code.includes("discard"),
|
|
||||||
).code,
|
|
||||||
/canvasOutput/,
|
|
||||||
);
|
|
||||||
await masked.dispose();
|
|
||||||
await mesh.dispose();
|
|
||||||
assertHdr();
|
|
||||||
const ldr = await createScene({ directToCanvas: true, hdr: false });
|
|
||||||
const ldrMaterial = await new PBRMaterial(ldr).ready;
|
|
||||||
await detailedMesh(ldr, ldrMaterial).ready;
|
|
||||||
assert.ok(renderPipeline(latestGraph(ldr), "present"));
|
|
||||||
});
|
|
||||||
@@ -1,81 +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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decodes the active indirect rows of every forward bucket pass, in pass order. */
|
|
||||||
export function bucketDraws(scene: Scene, graph: any) {
|
|
||||||
return forwardPasses(graph)
|
|
||||||
.filter((pass: any) => pass.indirect)
|
|
||||||
.flatMap((pass: any) => {
|
|
||||||
const buffer = graph.resources.buffers.find(
|
|
||||||
(buffer: any) => buffer.id === pass.indirect.resource,
|
|
||||||
);
|
|
||||||
const view = scene.core.array(buffer.array).view;
|
|
||||||
const draws = [];
|
|
||||||
for (let row = 0; row * 8 < view.length; row++) {
|
|
||||||
const [indices, instances, firstIndex, baseVertex, firstInstance] =
|
|
||||||
view.subarray(row * 8, row * 8 + 5);
|
|
||||||
if (instances)
|
|
||||||
draws.push({
|
|
||||||
pass: pass.id,
|
|
||||||
indices,
|
|
||||||
firstIndex,
|
|
||||||
baseVertex,
|
|
||||||
firstInstance,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return draws;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,23 +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.
|
|
||||||
`YAWN_SCREENSHOT_BASELINES` points at another reference directory (for example
|
|
||||||
one captured from a known-good commit on a machine whose software renderer does
|
|
||||||
not match the reviewed references), and `YAWN_SCREENSHOT_SETTLE_MS` (default
|
|
||||||
1200) is how long the viewer gets to settle after the model loads before the
|
|
||||||
capture; raise it on slow software renderers.
|
|
||||||
Failed full-size captures and visual diffs are written to `output/`.
|
|
||||||
|
|
||||||
To intentionally replace references after reviewing full-size renders, run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
UPDATE_SCREENSHOTS=1 npm run test:handles:screenshots
|
|
||||||
```
|
|
||||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 30 KiB |