Rewrite core around shared rows and render graphs
Co-authored-by: Heaust Azure <heaust.azure@gmail.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae
This commit is contained in:
@@ -1,86 +1,50 @@
|
||||
export const gltfShader = /* wgsl */ `
|
||||
struct UniformData { resolution: vec2<f32>, time: f32, _padding0: f32, camera_position: vec4<f32> }
|
||||
struct MaterialData { base_color_factor: vec4<f32>, emissive_factor: vec4<f32>, surface_factors: vec4<f32>, alpha_optics: vec4<f32>, flags: vec4<u32>, uv_sets: vec4<u32>, debug_extras: vec4<u32> }
|
||||
@group(0) @binding(0) var<uniform> uni: UniformData;
|
||||
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
|
||||
@group(2) @binding(0) var<uniform> material: MaterialData;
|
||||
@group(2) @binding(1) var base_tex: texture_2d<f32>;
|
||||
@group(2) @binding(2) var mr_tex: texture_2d<f32>;
|
||||
@group(2) @binding(3) var normal_tex: texture_2d<f32>;
|
||||
@group(2) @binding(4) var occlusion_tex: texture_2d<f32>;
|
||||
@group(2) @binding(5) var emissive_tex: texture_2d<f32>;
|
||||
@group(2) @binding(6) var base_sampler: sampler;
|
||||
@group(2) @binding(7) var mr_sampler: sampler;
|
||||
@group(2) @binding(8) var normal_sampler: sampler;
|
||||
@group(2) @binding(9) var occlusion_sampler: sampler;
|
||||
@group(2) @binding(10) var emissive_sampler: sampler;
|
||||
struct VertexInput { @location(0) pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) uv: vec2<f32>, @location(3) model_col0: vec4<f32>, @location(4) model_col1: vec4<f32>, @location(5) model_col2: vec4<f32>, @location(6) model_col3: vec4<f32>, @location(7) normal_col0: vec4<f32>, @location(8) normal_col1: vec4<f32>, @location(9) normal_col2: vec4<f32>, @location(10) tangent: vec4<f32> }
|
||||
struct VertexOutput { @builtin(position) clip_position: vec4<f32>, @location(0) world_pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) tangent: vec3<f32>, @location(3) bitangent: vec3<f32>, @location(4) uv: vec2<f32>, @location(5) @interpolate(flat) determinant_sign: f32 }
|
||||
fn safe_normalize(v: vec3<f32>, fallback: vec3<f32>) -> vec3<f32> { let l2 = dot(v, v); return select(fallback, v * inverseSqrt(l2), l2 > 1e-12 && l2 < 1e30); }
|
||||
@vertex fn vs_main(in: VertexInput) -> VertexOutput {
|
||||
var out: VertexOutput; let model = mat4x4<f32>(in.model_col0, in.model_col1, in.model_col2, in.model_col3);
|
||||
let linear = mat3x3<f32>(in.model_col0.xyz, in.model_col1.xyz, in.model_col2.xyz); let nm = mat3x3<f32>(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz);
|
||||
let world = model * vec4<f32>(in.pos, 1.0); let n = safe_normalize(nm * in.normal, vec3<f32>(0,1,0)); let raw_t = linear * in.tangent.xyz;
|
||||
var t = raw_t - n * dot(n, raw_t); if dot(t,t) < 1e-8 { t = cross(select(vec3<f32>(0,1,0), vec3<f32>(1,0,0), abs(n.x) < 0.9), n); } t = safe_normalize(t, vec3<f32>(1,0,0));
|
||||
out.clip_position = view_proj * world; out.world_pos = world.xyz; out.normal = n; out.tangent = t; out.bitangent = safe_normalize(cross(n,t), vec3<f32>(0,0,1)) * in.tangent.w * in.normal_col0.w; out.uv = in.uv; out.determinant_sign = in.normal_col0.w; return out;
|
||||
}
|
||||
struct Closure { base: vec4<f32>, mr: vec2<f32>, normal_map: vec3<f32>, ao: f32, emissive: vec3<f32> }
|
||||
fn sample_closure(uv: vec2<f32>) -> Closure {
|
||||
let bits = material.flags.x; var c: Closure;
|
||||
c.base = material.base_color_factor * select(vec4<f32>(1), textureSample(base_tex, base_sampler, uv), (bits & 1u) != 0u);
|
||||
let mr = select(vec4<f32>(1), textureSample(mr_tex, mr_sampler, uv), (bits & 2u) != 0u); c.mr = vec2<f32>(clamp(material.surface_factors.x * mr.b,0,1), clamp(material.surface_factors.y * mr.g,0.045,1));
|
||||
c.normal_map = select(vec3<f32>(0.5,0.5,1), textureSample(normal_tex, normal_sampler, uv).xyz, (bits & 4u) != 0u);
|
||||
let occ = select(1.0, textureSample(occlusion_tex, occlusion_sampler, uv).r, (bits & 8u) != 0u); c.ao = mix(1.0, occ, material.surface_factors.w);
|
||||
c.emissive = material.emissive_factor.rgb * select(vec3<f32>(1), textureSample(emissive_tex, emissive_sampler, uv).rgb, (bits & 16u) != 0u); return c;
|
||||
}
|
||||
fn schlick(f0: vec3<f32>, v_h: f32) -> vec3<f32> { return f0 + (vec3<f32>(1)-f0) * pow(1.0-clamp(v_h,0,1),5.0); }
|
||||
fn ggx_d(n_h_input: f32, a: f32) -> f32 { let n_h=clamp(n_h_input,0.0,1.0); let a2=a*a; let nh2=n_h*n_h; let q=(1.0-nh2)+a2*nh2; return a2/(3.14159265*q*q); }
|
||||
fn smith_v(n_v: f32, n_l: f32, a: f32) -> f32 { let a2=a*a; let gv=n_l*sqrt(max(n_v*n_v*(1.0-a2)+a2,0)); let gl=n_v*sqrt(max(n_l*n_l*(1.0-a2)+a2,0)); return 0.5/max(gv+gl,1e-6); }
|
||||
@fragment fn fs_main(in: VertexOutput, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let c=sample_closure(in.uv); if material.alpha_optics.x == 1.0 && c.base.a < material.alpha_optics.y { discard; }
|
||||
let physical_front=front == (in.determinant_sign > 0); let orientation=select(-1.0,1.0,physical_front || material.flags.y == 0u); let map=c.normal_map*2.0-1.0;
|
||||
let n=safe_normalize(mat3x3<f32>(in.tangent,in.bitangent,in.normal)*safe_normalize(vec3<f32>(map.xy*material.surface_factors.z,map.z),vec3<f32>(0,0,1)),in.normal)*orientation;
|
||||
let v=safe_normalize(uni.camera_position.xyz-in.world_pos,n); let l=safe_normalize(vec3<f32>(0.35,1,0.45),vec3<f32>(0,1,0)); let h=safe_normalize(v+l,n);
|
||||
let nv=max(dot(n,v),0); let nl=max(dot(n,l),0); let nh=dot(n,h); let vh=max(dot(v,h),0); let a=c.mr.y*c.mr.y;
|
||||
let f0=mix(vec3<f32>(material.alpha_optics.w),c.base.rgb,c.mr.x); let direct_f=schlick(f0,vh); let env_f=schlick(f0,nv); let spec=direct_f*ggx_d(nh,a)*smith_v(nv,nl,a); let diffuse=(vec3<f32>(1)-direct_f)*(1.0-c.mr.x)*c.base.rgb/3.14159265;
|
||||
let sun=(diffuse+spec)*nl*vec3<f32>(3.0,2.85,2.65); let up=clamp(n.y*0.5+0.5,0,1); let sky=mix(vec3<f32>(0.055,0.045,0.035),vec3<f32>(0.24,0.36,0.58),up);
|
||||
let reflection=reflect(-v,n); let horizon=clamp(reflection.y*0.5+0.5,0,1); let env_spec=env_f*mix(vec3<f32>(0.04,0.035,0.03),vec3<f32>(0.28,0.42,0.7),horizon)*(1.0-0.65*c.mr.y);
|
||||
let color=sun+(vec3<f32>(1)-env_f)*(1.0-c.mr.x)*c.base.rgb*sky*c.ao+env_spec*c.ao+c.emissive; return vec4<f32>(color,1.0);
|
||||
}`;
|
||||
const triangleShader = /* wgsl */ `
|
||||
struct Tint { color: vec4<f32> }
|
||||
@group(0) @binding(0) var<uniform> tint: Tint;
|
||||
|
||||
export const groundShader = /* wgsl */ `
|
||||
@group(0) @binding(0) var<uniform> application: array<vec4<f32>, 3>;
|
||||
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
|
||||
struct Input { @location(0) position: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) uv: vec2<f32>, @location(3) c0: vec4<f32>, @location(4) c1: vec4<f32>, @location(5) c2: vec4<f32>, @location(6) c3: vec4<f32> }
|
||||
struct Output { @builtin(position) position: vec4<f32>, @location(0) normal: vec3<f32> }
|
||||
@vertex fn vs_main(input: Input) -> Output { var output: Output; output.position=view_proj*mat4x4<f32>(input.c0,input.c1,input.c2,input.c3)*vec4(input.position,1); output.normal=input.normal; return output; }
|
||||
@fragment fn fs_main(input: Output) -> @location(0) vec4<f32> { return vec4(vec3(0.12)+max(input.normal.y,0.0)*vec3(0.16),1); }
|
||||
struct Vertex { @builtin(position) position: vec4<f32> }
|
||||
|
||||
@vertex fn vertex(@builtin(vertex_index) index: u32) -> Vertex {
|
||||
let positions = array(vec2(-0.75, -0.65), vec2(0.75, -0.65), vec2(0.0, 0.75));
|
||||
var output: Vertex;
|
||||
output.position = vec4(positions[index], 0.0, 1.0);
|
||||
return output;
|
||||
}
|
||||
|
||||
@fragment fn fragment() -> @location(0) vec4<f32> { return tint.color; }
|
||||
`;
|
||||
|
||||
export const frameShader = /* wgsl */ `
|
||||
@group(0) @binding(0) var source_texture: texture_2d<f32>;
|
||||
@group(0) @binding(1) var second_texture: texture_2d<f32>;
|
||||
@group(0) @binding(2) var linear_clamp: sampler;
|
||||
struct Parameters { values: array<vec4<f32>, 8> }
|
||||
@group(0) @binding(3) var<uniform> parameters: Parameters;
|
||||
struct VertexOut { @builtin(position) position: vec4<f32>, @location(0) uv: vec2<f32> }
|
||||
@vertex fn vs_main(@builtin(vertex_index) index: u32) -> VertexOut { let positions=array(vec2(-1.0,-1.0),vec2(3.0,-1.0),vec2(-1.0,3.0)); let p=positions[index]; var out:VertexOut; out.position=vec4(p,0,1); out.uv=p*vec2(0.5,-0.5)+vec2(0.5); return out; }
|
||||
fn aces(x:vec3<f32>)->vec3<f32>{return clamp((x*(2.51*x+vec3(0.03)))/(x*(2.43*x+vec3(0.59))+vec3(0.14)),vec3(0),vec3(1));}
|
||||
fn linear_to_srgb(x:vec3<f32>)->vec3<f32>{let safe=clamp(x,vec3(0),vec3(1));return select(1.055*pow(safe,vec3(1.0/2.4))-vec3(0.055),safe*12.92,safe<=vec3(0.0031308));}
|
||||
@fragment fn fs_frame_out(in:VertexOut)->@location(0) vec4<f32>{let sampled=textureSampleLevel(source_texture,linear_clamp,in.uv,0);var rgb=max(sampled.rgb*exp2(parameters.values[0].z),vec3(0));if parameters.values[0].x>0.5{if parameters.values[0].y>1.5{rgb=aces(rgb);}else if parameters.values[0].y>0.5{rgb=rgb/(vec3(1)+rgb);}}if parameters.values[0].w>0.5{rgb=linear_to_srgb(rgb);}return vec4(clamp(rgb,vec3(0),vec3(1)),clamp(sampled.a,0,1));}
|
||||
const noopComputeShader = /* wgsl */ `
|
||||
@compute @workgroup_size(1) fn main() {}
|
||||
`;
|
||||
|
||||
export const noopComputeShader = /* wgsl */ `@compute @workgroup_size(1) fn main() {}`;
|
||||
|
||||
/** Optional declarations copied into each graph that wants these implementations. */
|
||||
export const defaultPipelines = Object.freeze({
|
||||
render: Object.freeze([
|
||||
Object.freeze({ name: "ground_plane", shader: groundShader, vertexEntry: "vs_main", fragmentEntry: "fs_main" }),
|
||||
Object.freeze({ name: "gltf_standard", shader: gltfShader, vertexEntry: "vs_main", fragmentEntry: "fs_main", material: true }),
|
||||
Object.freeze({ name: "gltf_standard_double_sided", shader: gltfShader, vertexEntry: "vs_main", fragmentEntry: "fs_main", doubleSided: true, material: true }),
|
||||
Object.freeze({ name: "frame_out", shader: frameShader, vertexEntry: "vs_main", fragmentEntry: "fs_frame_out" }),
|
||||
]),
|
||||
compute: Object.freeze([
|
||||
Object.freeze({ name: "initialize_scene", shader: noopComputeShader, entry: "main", dispatch: [1, 1, 1] }),
|
||||
]),
|
||||
});
|
||||
/** A complete external graph used by the minimal playground; core contains neither program. */
|
||||
export function triangleGraph(colorArray = "triangle.color") {
|
||||
return {
|
||||
id: "triangle",
|
||||
resources: {
|
||||
buffers: [{ id: "color", array: colorArray, usage: ["uniform"] }],
|
||||
},
|
||||
pipelines: {
|
||||
compute: [{ id: "prepare", code: noopComputeShader }],
|
||||
render: [{
|
||||
id: "triangle",
|
||||
code: triangleShader,
|
||||
vertex: { entry: "vertex" },
|
||||
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
|
||||
}],
|
||||
},
|
||||
passes: [
|
||||
{ id: "prepare", type: "compute", pipeline: "prepare", dispatch: [1, 1, 1] },
|
||||
{
|
||||
id: "draw",
|
||||
type: "render",
|
||||
pipeline: "triangle",
|
||||
after: ["prepare"],
|
||||
bindings: [{ group: 0, binding: 0, resource: "color" }],
|
||||
color: [{ resource: "canvas", clear: [0.025, 0.035, 0.055, 1] }],
|
||||
draw: { vertices: 3 },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,75 +1,29 @@
|
||||
export class GltfImportError extends Error {
|
||||
constructor(code) {
|
||||
super(code);
|
||||
this.name = "GltfImportError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function frameCamera(core, bounds, framing) {
|
||||
if (!bounds || framing === false) return;
|
||||
if (framing !== undefined && framing !== "exterior" && framing !== "interior")
|
||||
throw new TypeError("framing must be exterior, interior, or false");
|
||||
const min = bounds.min, max = bounds.max;
|
||||
if (!Array.isArray(min) || !Array.isArray(max) || min.length !== 3 || max.length !== 3) return;
|
||||
const center = min.map((value, axis) => (value + max[axis]) * 0.5);
|
||||
const extent = max.map((value, axis) => value - min[axis]);
|
||||
const radius = Math.max(1, Math.hypot(...extent) * 0.5);
|
||||
const camera = core.array("camera.state");
|
||||
const state = camera.read(0);
|
||||
const interior = framing === "interior";
|
||||
const eye = interior
|
||||
? [center[0], center[1] + radius * 0.05, center[2]]
|
||||
: [center[0] + radius * 1.8, center[1] + radius * 1.4, center[2] + radius * 1.8];
|
||||
const target = interior ? [center[0] + radius, center[1], center[2]] : center;
|
||||
state.splice(0, 3, ...eye);
|
||||
state.splice(4, 3, ...target);
|
||||
state.splice(8, 3, 0, 1, 0);
|
||||
state[14] = Math.max(radius * 0.001, 0.1);
|
||||
state[15] = Math.max(radius * 6, 1.1);
|
||||
camera.write(0, state);
|
||||
}
|
||||
|
||||
/** Parses glTF in a dedicated worker and publishes a generic render-data packet through shared memory. */
|
||||
/** Fetches and parses glTF in a worker, then lets that worker write the packet into Yawn's SAB arena. */
|
||||
export class GltfImporter {
|
||||
#core;
|
||||
#worker;
|
||||
#next = 1;
|
||||
#pending = new Map();
|
||||
#tail = Promise.resolve();
|
||||
#disposed = false;
|
||||
|
||||
constructor(core, { workerFactory } = {}) {
|
||||
if (!core?.allocateArray || !core?.commitRenderDataUpload)
|
||||
throw new TypeError("core must implement the Yawn shared render-data protocol");
|
||||
if (!core?.allocateRows) throw new TypeError("core must be a YawnCore instance");
|
||||
this.#core = core;
|
||||
this.#worker = workerFactory
|
||||
? workerFactory()
|
||||
: new Worker(new URL("./worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
name: "yawn-gltf-import",
|
||||
});
|
||||
this.#worker.addEventListener("message", event => this.#message(event.data));
|
||||
this.#worker = workerFactory?.() ?? new Worker(new URL("./worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
name: "yawn-gltf-import",
|
||||
});
|
||||
this.#worker.addEventListener("message", ({ data }) => this.#message(data));
|
||||
this.#worker.addEventListener("error", () => this.#fail("GLTF_WORKER_ERROR"));
|
||||
this.#worker.addEventListener("messageerror", () => this.#fail("GLTF_WORKER_ERROR"));
|
||||
this.#worker.start?.();
|
||||
}
|
||||
|
||||
load(url, options = {}) {
|
||||
if (this.#disposed) return Promise.reject(new GltfImportError("DISPOSED"));
|
||||
load(url) {
|
||||
const source = url instanceof URL ? url.href : url;
|
||||
if (typeof source !== "string" || !source) return Promise.reject(new TypeError("url must be a URL or nonempty string"));
|
||||
const operation = this.#tail.then(() => this.#start(source, options));
|
||||
this.#tail = operation.catch(() => {});
|
||||
return operation;
|
||||
}
|
||||
|
||||
#start(url, options) {
|
||||
let request = this.#next++ >>> 0;
|
||||
if (!request) request = this.#next++ >>> 0;
|
||||
if (typeof source !== "string" || !source) throw new TypeError("url is required");
|
||||
const request = this.#next++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#pending.set(request, { resolve, reject, options, array: null });
|
||||
this.#worker.postMessage({ type: "load", request, url });
|
||||
this.#pending.set(request, { resolve, reject });
|
||||
this.#worker.postMessage({ type: "load", request, url: source });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,31 +32,17 @@ export class GltfImporter {
|
||||
if (!pending) return;
|
||||
try {
|
||||
if (message.type === "allocate") {
|
||||
const length = Math.ceil(message.byteLength / 16);
|
||||
pending.array = await this.#core.allocateArray({
|
||||
name: "upload.renderData",
|
||||
domain: "fixed",
|
||||
scalar: "u32",
|
||||
lanes: 4,
|
||||
pending.array = await this.#core.allocateRows({
|
||||
name: `gltf.${message.request}`,
|
||||
rows: Math.ceil(message.byteLength / 16),
|
||||
stride: 16,
|
||||
length,
|
||||
format: "u32",
|
||||
});
|
||||
this.#worker.postMessage({
|
||||
type: "storage",
|
||||
request: message.request,
|
||||
...pending.array.share(),
|
||||
});
|
||||
} else if (message.type === "ready") {
|
||||
const result = await this.#core.commitRenderDataUpload(
|
||||
pending.array,
|
||||
message.byteLength,
|
||||
);
|
||||
frameCamera(this.#core, result.bounds, pending.options.framing);
|
||||
this.#worker.postMessage({ type: "storage", request: message.request, ...pending.array.share() });
|
||||
} else {
|
||||
this.#pending.delete(message.request);
|
||||
pending.resolve(result);
|
||||
} else if (message.type === "error") {
|
||||
this.#pending.delete(message.request);
|
||||
pending.reject(new GltfImportError(message.code || "GLTF_IMPORT_FAILED"));
|
||||
if (message.type === "ready") pending.resolve({ array: pending.array, byteLength: message.byteLength });
|
||||
else pending.reject(new Error(message.error ?? "GLTF_IMPORT_FAILED"));
|
||||
}
|
||||
} catch (error) {
|
||||
this.#pending.delete(message.request);
|
||||
@@ -111,16 +51,12 @@ export class GltfImporter {
|
||||
}
|
||||
|
||||
#fail(code) {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
const error = new GltfImportError(code);
|
||||
for (const pending of this.#pending.values()) pending.reject(error);
|
||||
for (const { reject } of this.#pending.values()) reject(new Error(code));
|
||||
this.#pending.clear();
|
||||
this.#worker.terminate?.();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.#disposed) return;
|
||||
this.#fail("DISPOSED");
|
||||
this.#worker.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
const MAGIC = 0x414f5359;
|
||||
|
||||
/** Publishes one byte payload into a packed fixed SOA allocation. */
|
||||
export function writeSharedUpload(buffer, descriptor, bytes) {
|
||||
if (!(buffer instanceof SharedArrayBuffer) || !(bytes instanceof Uint8Array))
|
||||
throw new TypeError("shared upload requires SharedArrayBuffer storage and Uint8Array bytes");
|
||||
if (
|
||||
!descriptor ||
|
||||
descriptor.domain !== "fixed" ||
|
||||
descriptor.scalar !== "u32" ||
|
||||
descriptor.stride !== descriptor.lanes * 4 ||
|
||||
descriptor.controlPtr % 64 !== 0 ||
|
||||
descriptor.dataOffset !== 64 ||
|
||||
bytes.byteLength < 1 ||
|
||||
bytes.byteLength > descriptor.length * descriptor.lanes * 4
|
||||
) throw new TypeError("invalid packed fixed SOA upload");
|
||||
|
||||
const control = new Int32Array(buffer, descriptor.controlPtr, 16);
|
||||
if (
|
||||
(Atomics.load(control, 0) >>> 0) !== MAGIC ||
|
||||
(Atomics.load(control, 2) >>> 0) !== descriptor.id
|
||||
) throw new Error("SOA_PROTOCOL_MISMATCH");
|
||||
|
||||
let sequence;
|
||||
for (let attempt = 0; attempt < 1024; attempt++) {
|
||||
const candidate = Atomics.load(control, 9) >>> 0;
|
||||
if (!(candidate & 1) && (Atomics.compareExchange(control, 9, candidate | 0, (candidate + 1) | 0) >>> 0) === candidate) {
|
||||
sequence = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sequence === undefined) throw new Error("SOA_BUSY");
|
||||
try {
|
||||
new Uint8Array(
|
||||
buffer,
|
||||
descriptor.controlPtr + descriptor.dataOffset,
|
||||
bytes.byteLength,
|
||||
).set(bytes);
|
||||
} finally {
|
||||
Atomics.store(control, 9, (sequence + 2) | 0);
|
||||
Atomics.notify(control, 9);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { writeSharedUpload } from "./shared-upload.js";
|
||||
import { gltfToRenderDataPacket } from "./gltf.js";
|
||||
|
||||
const downloads = new Map();
|
||||
@@ -19,12 +18,16 @@ addEventListener("message", async ({ data: message }) => {
|
||||
if (message?.type === "storage") {
|
||||
const packet = downloads.get(request);
|
||||
if (!packet) throw new Error("GLTF_REQUEST_UNKNOWN");
|
||||
writeSharedUpload(message.buffer, message.descriptor, packet);
|
||||
const { buffer, descriptor } = message;
|
||||
if (!(buffer instanceof SharedArrayBuffer) || descriptor?.format !== "u32" ||
|
||||
descriptor.stride !== 16 || descriptor.offset % 64 || packet.byteLength > descriptor.rows * descriptor.stride)
|
||||
throw new Error("GLTF_STORAGE_INVALID");
|
||||
new Uint8Array(buffer, descriptor.offset, packet.byteLength).set(packet);
|
||||
downloads.delete(request);
|
||||
postMessage({ type: "ready", request, byteLength: packet.byteLength });
|
||||
}
|
||||
} catch (error) {
|
||||
downloads.delete(request);
|
||||
postMessage({ type: "error", request, code: error?.message || "GLTF_IMPORT_FAILED" });
|
||||
postMessage({ type: "error", request, error: error?.message || "GLTF_IMPORT_FAILED" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,8 +3,5 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Conventional mesh, instance, camera, and material handles over Yawn render data",
|
||||
"type": "module",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@yawn/core": "0.1.0"
|
||||
}
|
||||
"exports": "./src/index.js"
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/** Worker-local acceleration structure used by the optional mesh-handle addon. */
|
||||
export class DerivedBvh {
|
||||
constructor() { this.count = 0; this.identity = new Uint32Array(); this.meshIdentity = new Uint32Array(); this.bounds = new Float32Array(); this.nodeBounds = new Float32Array(); this.left = this.right = new Int32Array(); this.leafStart = this.leafCount = new Uint32Array(); this.leaves = new Uint32Array(); this.root = -1; this.rebuilds = 0; this.refits = 0; }
|
||||
update(snapshot) {
|
||||
const s = snapshot.streams, n = snapshot.instanceCount;
|
||||
let changed = n !== this.count;
|
||||
if (!changed) for (let i = 0; i < n; i++) if (this.identity[i * 2] !== s.instanceSlot[i] || this.identity[i * 2 + 1] !== s.instanceGeneration[i] || this.meshIdentity[i * 2] !== s.instanceMeshSlot[i] || this.meshIdentity[i * 2 + 1] !== s.instanceMeshGeneration[i]) { changed = true; break; }
|
||||
this.count = n; this.identity = new Uint32Array(n * 2); this.meshIdentity = new Uint32Array(n * 2); this.bounds = new Float32Array(n * 6);
|
||||
for (let i = 0; i < n; i++) { this.identity.set([s.instanceSlot[i], s.instanceGeneration[i]], i * 2); this.meshIdentity.set([s.instanceMeshSlot[i], s.instanceMeshGeneration[i]], i * 2); this.bounds.set(s.instanceWorldMin.subarray(i * 3, i * 3 + 3), i * 6); this.bounds.set(s.instanceWorldMax.subarray(i * 3, i * 3 + 3), i * 6 + 3); }
|
||||
changed ? this.rebuild() : this.refit();
|
||||
}
|
||||
rebuild() {
|
||||
this.rebuilds++; const nodes = [], leaves = [];
|
||||
const build = indices => { const at = nodes.length, node = {left: -1, right: -1, start: 0, count: 0, bounds: [Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity]}; nodes.push(node); for (const i of indices) for (let a = 0; a < 3; a++) { node.bounds[a] = Math.min(node.bounds[a], this.bounds[i * 6 + a]); node.bounds[a + 3] = Math.max(node.bounds[a + 3], this.bounds[i * 6 + a + 3]); } if (indices.length <= 2) { node.start = leaves.length; node.count = indices.length; leaves.push(...indices); return at; } let axis = 0, extent = node.bounds[3] - node.bounds[0]; for (let a = 1; a < 3; a++) if (node.bounds[a + 3] - node.bounds[a] > extent) { axis = a; extent = node.bounds[a + 3] - node.bounds[a]; } indices.sort((a, b) => (this.bounds[a * 6 + axis] + this.bounds[a * 6 + axis + 3]) - (this.bounds[b * 6 + axis] + this.bounds[b * 6 + axis + 3]) || a - b); const mid = indices.length >> 1; node.left = build(indices.slice(0, mid)); node.right = build(indices.slice(mid)); return at; };
|
||||
this.root = this.count ? build(Array.from({length: this.count}, (_, i) => i)) : -1; const n = nodes.length;
|
||||
this.nodeBounds = new Float32Array(n * 6); this.left = new Int32Array(n); this.right = new Int32Array(n); this.leafStart = new Uint32Array(n); this.leafCount = new Uint32Array(n); this.leaves = Uint32Array.from(leaves);
|
||||
nodes.forEach((x, i) => { this.nodeBounds.set(x.bounds, i * 6); this.left[i] = x.left; this.right[i] = x.right; this.leafStart[i] = x.start; this.leafCount[i] = x.count; });
|
||||
}
|
||||
refit() { this.refits++; for (let n = this.left.length - 1; n >= 0; n--) { const at = n * 6; for (let a = 0; a < 3; a++) { let lo = Infinity, hi = -Infinity; if (this.leafCount[n]) for (let j = 0; j < this.leafCount[n]; j++) { const i = this.leaves[this.leafStart[n] + j]; lo = Math.min(lo, this.bounds[i * 6 + a]); hi = Math.max(hi, this.bounds[i * 6 + a + 3]); } else { lo = Math.min(this.nodeBounds[this.left[n] * 6 + a], this.nodeBounds[this.right[n] * 6 + a]); hi = Math.max(this.nodeBounds[this.left[n] * 6 + a + 3], this.nodeBounds[this.right[n] * 6 + a + 3]); } this.nodeBounds[at + a] = lo; this.nodeBounds[at + a + 3] = hi; } } }
|
||||
pick(origin, direction, maxDistance = Infinity, maxHits = 1) {
|
||||
if (this.root < 0) return []; let magnitude = Math.hypot(...direction); const dir = direction.map(v => v / magnitude);
|
||||
const intersect = (array, at) => { let lo = 0, hi = maxDistance; for (let a = 0; a < 3; a++) { const min = array[at + a], max = array[at + a + 3]; if (dir[a] === 0) { if (origin[a] < min || origin[a] > max) return Infinity; } else { let x = (min - origin[a]) / dir[a], y = (max - origin[a]) / dir[a]; if (x > y) [x, y] = [y, x]; lo = Math.max(lo, x); hi = Math.min(hi, y); if (lo > hi) return Infinity; } } return lo; };
|
||||
const hits = [], stack = [this.root]; while (stack.length) { const n = stack.pop(); if (intersect(this.nodeBounds, n * 6) === Infinity) continue; if (this.leafCount[n]) for (let j = 0; j < this.leafCount[n]; j++) { const i = this.leaves[this.leafStart[n] + j]; const distance = intersect(this.bounds, i * 6); if (distance !== Infinity) hits.push({slot: this.identity[i * 2], generation: this.identity[i * 2 + 1], distance}); } else stack.push(this.right[n], this.left[n]); }
|
||||
hits.sort((a, b) => a.distance - b.distance || a.slot - b.slot || a.generation - b.generation); return hits.slice(0, maxHits);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { SnapshotReader } from "./snapshot.js";
|
||||
import { DerivedBvh } from "./bvh-core.js";
|
||||
|
||||
let reader, bvh = new DerivedBvh(), epoch = 0, updating = false, requestedEpoch = 0;
|
||||
function ensureEpoch(expected) {
|
||||
if (!reader) return false;
|
||||
const latest = reader.latest();
|
||||
if (latest.epoch !== expected) return false;
|
||||
if (epoch === expected) return true;
|
||||
const result = reader.transaction(snapshot => { bvh.update(snapshot); epoch = snapshot.epoch; }, expected);
|
||||
return result !== null && epoch === expected;
|
||||
}
|
||||
function coalescedUpdate(hint = 0) {
|
||||
requestedEpoch = Math.max(requestedEpoch, hint >>> 0);
|
||||
if (updating) return;
|
||||
updating = true;
|
||||
queueMicrotask(() => { try { const latest = reader?.latest(); if (latest?.epoch && latest.epoch !== epoch) ensureEpoch(latest.epoch); if (epoch) postMessage({type: "updated", epoch}); } catch (error) { postMessage({type: "fatal", code: "PICK_PROTOCOL_MISMATCH", message: String(error)}); } finally { updating = false; if (requestedEpoch > epoch) coalescedUpdate(); } });
|
||||
}
|
||||
addEventListener("message", event => {
|
||||
const m = event.data;
|
||||
try {
|
||||
if (m.type === "init") { reader = new SnapshotReader(m.memory, m.controlPtr); if (m.controlVersion !== 1 || m.schemaVersion !== 2) throw Object.assign(new Error("version"), {code: "PICK_PROTOCOL_MISMATCH"}); postMessage({type: "ready"}); }
|
||||
else if (m.type === "update") coalescedUpdate(m.epoch);
|
||||
else if (m.type === "pick") { if (!ensureEpoch(m.epoch)) { postMessage({type: "pick", request: m.request, stale: true, epoch}); return; } const hits = bvh.pick(m.origin, m.direction, m.maxDistance, m.maxHits); const latest = reader.latest().epoch; postMessage({type: "pick", request: m.request, stale: latest !== m.epoch || epoch !== m.epoch, epoch, hits}); }
|
||||
else if (m.type === "dispose") close();
|
||||
} catch (error) { postMessage({type: "fatal", code: error.name === "SnapshotProtocolError" || error.code === "PICK_PROTOCOL_MISMATCH" ? "PICK_PROTOCOL_MISMATCH" : "PICK_WORKER_ERROR", message: String(error)}); }
|
||||
});
|
||||
@@ -1,362 +1,55 @@
|
||||
import { RendererError } from "@yawn/core";
|
||||
import { SnapshotReader } from "./snapshot.js";
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
|
||||
const TOKEN = Symbol("yawn mesh handle addon");
|
||||
const SNAPSHOT_EVENT = "yawn-render-data-snapshot";
|
||||
const PUBLISHED_EVENT = "yawn-render-data-snapshot-published";
|
||||
const createPickingWorker = () => new Worker(
|
||||
new URL("./bvh-worker.js", import.meta.url),
|
||||
{ type: "module", name: "yawn-spatial-query" },
|
||||
);
|
||||
|
||||
/** Conventional mesh/instance objects layered entirely over the Yawn core protocol. */
|
||||
export class MeshHandles {
|
||||
#core;
|
||||
#factory; #worker; #reader; #snapshot; #epoch = 0; #next = 1; #picks = new Map(); #disposed = false;
|
||||
#onSnapshot; #onPublished;
|
||||
|
||||
constructor(core, { pickingWorkerFactory = createPickingWorker } = {}) {
|
||||
this.#core = core;
|
||||
this.#factory = pickingWorkerFactory;
|
||||
this.#onSnapshot = event => this.#installSnapshot(event.detail);
|
||||
this.#onPublished = event => this.#publish(event.detail?.epoch);
|
||||
core.addEventListener?.(SNAPSHOT_EVENT, this.#onSnapshot);
|
||||
core.addEventListener?.(PUBLISHED_EVENT, this.#onPublished);
|
||||
if (core.renderDataSnapshot) this.#installSnapshot(core.renderDataSnapshot);
|
||||
class Handle {
|
||||
constructor(array, row = 0) {
|
||||
this.array = array;
|
||||
this.row = row;
|
||||
}
|
||||
|
||||
fromImportedScene(result) {
|
||||
if (!result || !Array.isArray(result.meshes)) throw new TypeError("invalid imported scene");
|
||||
return result.meshes.map((mesh) => new Mesh(TOKEN, this.#core, mesh));
|
||||
}
|
||||
|
||||
async pickRay(origin, direction, options) {
|
||||
const result = await this.#pickRay(origin, direction, options);
|
||||
return {
|
||||
...result,
|
||||
hits: result.hits.map((hit) => ({
|
||||
...hit,
|
||||
instance: new Instance(TOKEN, this.#core, hit.instance),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
#installSnapshot(snapshot) {
|
||||
try {
|
||||
if (snapshot?.controlVersion !== 1 || snapshot?.schemaVersion !== 2) throw new Error("version");
|
||||
this.#snapshot = snapshot;
|
||||
this.#reader = new SnapshotReader(snapshot.memory, snapshot.controlPtr);
|
||||
this.#epoch = this.#reader.latest().epoch;
|
||||
this.#worker?.postMessage({ type: "init", ...snapshot });
|
||||
} catch {
|
||||
this.#disable("PICK_PROTOCOL_MISMATCH");
|
||||
}
|
||||
}
|
||||
|
||||
#publish(epoch) {
|
||||
if (this.#disposed || !this.#reader) return;
|
||||
try {
|
||||
this.#epoch = this.#reader.latest().epoch;
|
||||
this.#worker?.postMessage({ type: "update", epoch: this.#epoch || (epoch >>> 0) });
|
||||
} catch {
|
||||
this.#disable("PICK_PROTOCOL_MISMATCH");
|
||||
}
|
||||
}
|
||||
|
||||
#ensureWorker() {
|
||||
if (this.#worker) return true;
|
||||
if (!this.#reader || !this.#factory) return false;
|
||||
try {
|
||||
this.#worker = this.#factory();
|
||||
this.#worker.addEventListener("message", event => this.#workerMessage(event.data));
|
||||
this.#worker.addEventListener("error", () => this.#disable("PICK_WORKER_ERROR"));
|
||||
this.#worker.addEventListener("messageerror", () => this.#disable("PICK_WORKER_ERROR"));
|
||||
this.#worker.start?.();
|
||||
this.#worker.postMessage({ type: "init", ...this.#snapshot });
|
||||
if (this.#epoch) this.#worker.postMessage({ type: "update", epoch: this.#epoch });
|
||||
return true;
|
||||
} catch {
|
||||
this.#disable("PICK_WORKER_ERROR");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#disable(code) {
|
||||
const error = new RendererError(code);
|
||||
for (const pending of this.#picks.values()) pending.reject(error);
|
||||
this.#picks.clear();
|
||||
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
|
||||
this.#worker = null;
|
||||
}
|
||||
|
||||
#workerMessage(message) {
|
||||
if (message?.type === "fatal") { this.#disable(message.code || "PICK_WORKER_ERROR"); return; }
|
||||
if (message?.type !== "pick") return;
|
||||
const pending = this.#picks.get(message.request);
|
||||
if (!pending) return;
|
||||
this.#picks.delete(message.request);
|
||||
let latest;
|
||||
try { latest = this.#reader.latest().epoch; this.#epoch = latest; }
|
||||
catch { pending.reject(new RendererError("PICK_PROTOCOL_MISMATCH")); this.#disable("PICK_PROTOCOL_MISMATCH"); return; }
|
||||
if (message.stale || pending.epoch !== message.epoch || message.epoch !== latest) {
|
||||
if (!pending.retried && latest) this.#sendPick({ ...pending, retried: true }, latest);
|
||||
else pending.reject(new RendererError("PICK_STALE"));
|
||||
return;
|
||||
}
|
||||
pending.resolve({
|
||||
epoch: latest,
|
||||
hits: (message.hits || []).map(hit => ({
|
||||
instance: [hit.slot >>> 0, hit.generation >>> 0],
|
||||
distance: hit.distance,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
#sendPick(pending, epoch) {
|
||||
const request = this.#next++ >>> 0 || this.#next++;
|
||||
pending.epoch = epoch;
|
||||
this.#picks.set(request, pending);
|
||||
try {
|
||||
this.#worker.postMessage({
|
||||
type: "pick", request, epoch,
|
||||
origin: pending.origin, direction: pending.direction,
|
||||
maxDistance: pending.maxDistance, maxHits: pending.maxHits,
|
||||
});
|
||||
} catch {
|
||||
this.#picks.delete(request);
|
||||
pending.reject(new RendererError("PICK_WORKER_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
#pickRay(origin, direction, { maxDistance = Infinity, maxHits = 1 } = {}) {
|
||||
const vector = (value, name) => {
|
||||
if (!value || value.length !== 3 || [...value].some(x => typeof x !== "number" || !Number.isFinite(x)))
|
||||
throw new TypeError(`${name} must contain 3 finite numbers`);
|
||||
return [...value];
|
||||
};
|
||||
origin = vector(origin, "origin");
|
||||
direction = vector(direction, "direction");
|
||||
if (direction.every(x => x === 0)) throw new TypeError("direction must be nonzero");
|
||||
if (typeof maxDistance !== "number" || (!(Number.isFinite(maxDistance) && maxDistance >= 0) && maxDistance !== Infinity) || !Number.isInteger(maxHits) || maxHits < 1 || maxHits > 64)
|
||||
throw new TypeError("invalid pick options");
|
||||
if (this.#disposed) return Promise.reject(new RendererError("DISPOSED"));
|
||||
if (!this.#ensureWorker()) return Promise.reject(new RendererError("PICK_UNAVAILABLE"));
|
||||
let epoch;
|
||||
try { epoch = this.#reader.latest().epoch; this.#epoch = epoch; }
|
||||
catch { return Promise.reject(new RendererError("PICK_PROTOCOL_MISMATCH")); }
|
||||
if (!epoch) return Promise.reject(new RendererError("PICK_STALE"));
|
||||
return new Promise((resolve, reject) => this.#sendPick({
|
||||
resolve, reject, origin, direction, maxDistance, maxHits, retried: false,
|
||||
}, epoch));
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#core.removeEventListener?.(SNAPSHOT_EVENT, this.#onSnapshot);
|
||||
this.#core.removeEventListener?.(PUBLISHED_EVENT, this.#onPublished);
|
||||
try { this.#worker?.postMessage?.({ type: "dispose" }); } catch { /* best effort */ }
|
||||
this.#disable("DISPOSED");
|
||||
}
|
||||
}
|
||||
|
||||
export class Mesh {
|
||||
#core; #handle; #defaultInstance; #defaultType;
|
||||
|
||||
constructor(token, core, descriptor) {
|
||||
if (token !== TOKEN) throw new TypeError("Mesh cannot be constructed directly");
|
||||
this.#core = core;
|
||||
this.#handle = Object.freeze([...descriptor.handle]);
|
||||
this.#defaultInstance = new Instance(TOKEN, core, descriptor.defaultInstance);
|
||||
this.#defaultType = Object.freeze([...descriptor.defaultType]);
|
||||
}
|
||||
|
||||
get handle() { return this.#handle; }
|
||||
get defaultInstance() { return this.#defaultInstance; }
|
||||
|
||||
async createInstance(transform, { type = this.#defaultType } = {}) {
|
||||
return new Instance(TOKEN, this.#core, await this.#core.createInstance(this.#handle, transform, { type }));
|
||||
}
|
||||
}
|
||||
|
||||
export class Instance {
|
||||
#core; #handle; #dead = false;
|
||||
|
||||
constructor(token, core, handle) {
|
||||
if (token !== TOKEN) throw new TypeError("Instance cannot be constructed directly");
|
||||
this.#core = core;
|
||||
this.#handle = Object.freeze([...handle]);
|
||||
}
|
||||
|
||||
get handle() { return this.#handle; }
|
||||
#live() { if (this.#dead) throw new Error("STALE_HANDLE"); }
|
||||
setType(words) { this.#live(); this.#core.setInstanceType(this.#handle, words); }
|
||||
setTransform(transform) { this.#live(); this.#core.setInstanceTransform(this.#handle, transform); }
|
||||
async destroy() { this.#live(); await this.#core.destroyInstance(this.#handle); this.#dead = true; }
|
||||
}
|
||||
|
||||
function requireArray(core, name, { domain, scalar, lanes }) {
|
||||
if (!core?.array) throw new TypeError("core must implement the Yawn shared render-data protocol");
|
||||
const array = core.array(name);
|
||||
if (array.domain !== domain || array.scalar !== scalar || array.lanes !== lanes)
|
||||
throw new RendererError("SOA_PROTOCOL_MISMATCH");
|
||||
return array;
|
||||
}
|
||||
|
||||
function vector(value, length, name) {
|
||||
if (!value || value.length !== length || [...value].some(item => typeof item !== "number" || !Number.isFinite(item)))
|
||||
throw new TypeError(`${name} must contain ${length} finite numbers`);
|
||||
return Array.from(value);
|
||||
}
|
||||
|
||||
function finite(value, name) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be finite`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Conventional camera properties backed by the canonical SIMD-width camera SOA row. */
|
||||
export class CameraHandle {
|
||||
#array;
|
||||
|
||||
constructor(core) {
|
||||
this.#array = requireArray(core, "camera.state", { domain: "fixed", scalar: "f32", lanes: 16 });
|
||||
}
|
||||
|
||||
get state() { return this.#array.read(0); }
|
||||
set state(value) { this.#write(vector(value, 16, "state")); }
|
||||
get position() { return this.state.slice(0, 3); }
|
||||
set position(value) { this.update({ position: value }); }
|
||||
get target() { return this.state.slice(4, 7); }
|
||||
set target(value) { this.update({ target: value }); }
|
||||
get up() { return this.state.slice(8, 11); }
|
||||
set up(value) { this.update({ up: value }); }
|
||||
get fovY() { return this.state[12]; }
|
||||
set fovY(value) { this.update({ fovY: value }); }
|
||||
get aspect() { return this.state[13]; }
|
||||
set aspect(value) { this.update({ aspect: value }); }
|
||||
get near() { return this.state[14]; }
|
||||
set near(value) { this.update({ near: value }); }
|
||||
get far() { return this.state[15]; }
|
||||
set far(value) { this.update({ far: value }); }
|
||||
|
||||
update(properties = {}) {
|
||||
if (!properties || typeof properties !== "object") throw new TypeError("camera properties must be an object");
|
||||
const known = new Set(["position", "target", "up", "fovY", "aspect", "near", "far"]);
|
||||
for (const key of Object.keys(properties)) if (!known.has(key)) throw new TypeError(`unknown camera property '${key}'`);
|
||||
get state() { return this.array.read(this.row); }
|
||||
set state(value) { this.array.write(this.row, value); }
|
||||
patch(offset, values) {
|
||||
const state = this.state;
|
||||
if (properties.position !== undefined) state.splice(0, 3, ...vector(properties.position, 3, "position"));
|
||||
if (properties.target !== undefined) state.splice(4, 3, ...vector(properties.target, 3, "target"));
|
||||
if (properties.up !== undefined) state.splice(8, 3, ...vector(properties.up, 3, "up"));
|
||||
if (properties.fovY !== undefined) state[12] = finite(properties.fovY, "fovY");
|
||||
if (properties.aspect !== undefined) state[13] = finite(properties.aspect, "aspect");
|
||||
if (properties.near !== undefined) state[14] = finite(properties.near, "near");
|
||||
if (properties.far !== undefined) state[15] = finite(properties.far, "far");
|
||||
this.#write(state);
|
||||
return this;
|
||||
}
|
||||
|
||||
lookAt(position, target, { up = this.up } = {}) {
|
||||
return this.update({ position, target, up });
|
||||
}
|
||||
|
||||
#write(state) {
|
||||
const offset = state.slice(0, 3).map((value, axis) => state[4 + axis] - value);
|
||||
const up = state.slice(8, 11);
|
||||
const cross = [
|
||||
offset[1] * up[2] - offset[2] * up[1],
|
||||
offset[2] * up[0] - offset[0] * up[2],
|
||||
offset[0] * up[1] - offset[1] * up[0],
|
||||
];
|
||||
if (Math.hypot(...offset) < 0.1 || Math.hypot(...up) === 0 || Math.hypot(...cross) === 0)
|
||||
throw new RangeError("camera position, target, and up do not define a view");
|
||||
if (!(state[12] > 0 && state[12] < Math.PI) || state[13] <= 0 || state[14] <= 0 || state[15] <= state[14])
|
||||
throw new RangeError("camera projection is invalid");
|
||||
state[3] = state[7] = 1;
|
||||
state[11] = 0;
|
||||
this.#array.write(0, state);
|
||||
state.splice(offset, values.length, ...values);
|
||||
this.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
const FLOAT_WORD = new ArrayBuffer(4);
|
||||
const FLOAT_VIEW = new Float32Array(FLOAT_WORD);
|
||||
const WORD_VIEW = new Uint32Array(FLOAT_WORD);
|
||||
function wordToFloat(word) { WORD_VIEW[0] = word; return FLOAT_VIEW[0]; }
|
||||
function floatToWord(value) { FLOAT_VIEW[0] = value; return WORD_VIEW[0]; }
|
||||
|
||||
/** Creates scene-scoped material objects over packed material SOA rows. */
|
||||
export class MaterialHandles {
|
||||
#array;
|
||||
|
||||
constructor(core) {
|
||||
this.#array = requireArray(core, "material.state", { domain: "fixed", scalar: "u32", lanes: 28 });
|
||||
}
|
||||
|
||||
fromImportedScene(result) {
|
||||
if (!result || !Array.isArray(result.materials)) throw new TypeError("invalid imported scene");
|
||||
return result.materials.map(material => this.get(material?.key));
|
||||
}
|
||||
|
||||
get(key) {
|
||||
if (!Number.isInteger(key) || key < 0 || key > 0xffffffff || key >= this.#array.length)
|
||||
throw new RangeError("material key is outside the shared material rows");
|
||||
return new MaterialHandle(TOKEN, this.#array, key);
|
||||
/** Conventional camera values over one caller-owned shared row. */
|
||||
export class CameraHandle extends Handle {
|
||||
static async create(core, name = "camera") {
|
||||
const array = await core.allocateRows({ name, rows: 1, stride: 64, format: "f32" });
|
||||
const camera = new CameraHandle(array);
|
||||
camera.state = [0, 0, 4, 0, 0, 0, 0, 0, 0, 1, 0, 0, Math.PI / 3, 1, 0.1, 1000];
|
||||
return camera;
|
||||
}
|
||||
get position() { return this.state.slice(0, 3); }
|
||||
set position(value) { this.patch(0, value); }
|
||||
get target() { return this.state.slice(4, 7); }
|
||||
set target(value) { this.patch(4, value); }
|
||||
}
|
||||
|
||||
export class MaterialHandle {
|
||||
#array; #key;
|
||||
|
||||
constructor(token, array, key) {
|
||||
if (token !== TOKEN) throw new TypeError("MaterialHandle cannot be constructed directly");
|
||||
this.#array = array;
|
||||
this.#key = key;
|
||||
/** Conventional material properties over one eight-float shared row. */
|
||||
export class MaterialHandle extends Handle {
|
||||
static async create(core, name = "material") {
|
||||
const material = new MaterialHandle(await core.allocateRows({ name, rows: 1, stride: 32, format: "f32" }));
|
||||
material.state = [1, 1, 1, 1, 0, 1, 0, 0];
|
||||
return material;
|
||||
}
|
||||
get baseColor() { return this.state.slice(0, 4); }
|
||||
set baseColor(value) { this.patch(0, value); }
|
||||
get metallic() { return this.state[4]; }
|
||||
set metallic(value) { this.patch(4, [value]); }
|
||||
get roughness() { return this.state[5]; }
|
||||
set roughness(value) { this.patch(5, [value]); }
|
||||
}
|
||||
|
||||
get key() { return this.#key; }
|
||||
get baseColor() { return this.#floats(0, 4); }
|
||||
set baseColor(value) { this.update({ baseColor: value }); }
|
||||
get emissive() { return this.#floats(4, 3); }
|
||||
set emissive(value) { this.update({ emissive: value }); }
|
||||
get metallic() { return this.#float(8); }
|
||||
set metallic(value) { this.update({ metallic: value }); }
|
||||
get roughness() { return this.#float(9); }
|
||||
set roughness(value) { this.update({ roughness: value }); }
|
||||
get normalScale() { return this.#float(10); }
|
||||
set normalScale(value) { this.update({ normalScale: value }); }
|
||||
get occlusionStrength() { return this.#float(11); }
|
||||
set occlusionStrength(value) { this.update({ occlusionStrength: value }); }
|
||||
get alphaCutoff() { return this.#float(13); }
|
||||
set alphaCutoff(value) { this.update({ alphaCutoff: value }); }
|
||||
get ior() { return this.#float(14); }
|
||||
set ior(value) { this.update({ ior: value }); }
|
||||
|
||||
update(properties = {}) {
|
||||
if (!properties || typeof properties !== "object") throw new TypeError("material properties must be an object");
|
||||
const known = new Set(["baseColor", "emissive", "metallic", "roughness", "normalScale", "occlusionStrength", "alphaCutoff", "ior"]);
|
||||
for (const key of Object.keys(properties)) if (!known.has(key)) throw new TypeError(`unknown material property '${key}'`);
|
||||
const words = this.#array.read(this.#key);
|
||||
const setFloat = (lane, value, name) => { words[lane] = floatToWord(finite(value, name)); };
|
||||
if (properties.baseColor !== undefined)
|
||||
vector(properties.baseColor, 4, "baseColor").forEach((value, lane) => setFloat(lane, value, "baseColor"));
|
||||
if (properties.emissive !== undefined)
|
||||
vector(properties.emissive, 3, "emissive").forEach((value, lane) => setFloat(4 + lane, value, "emissive"));
|
||||
for (const [name, lane] of [["metallic", 8], ["roughness", 9], ["normalScale", 10], ["occlusionStrength", 11], ["alphaCutoff", 13]]) {
|
||||
if (properties[name] !== undefined) setFloat(lane, properties[name], name);
|
||||
}
|
||||
if (properties.metallic !== undefined && !(properties.metallic >= 0 && properties.metallic <= 1)) throw new RangeError("metallic must be in [0, 1]");
|
||||
if (properties.roughness !== undefined && !(properties.roughness >= 0 && properties.roughness <= 1)) throw new RangeError("roughness must be in [0, 1]");
|
||||
if (properties.occlusionStrength !== undefined && !(properties.occlusionStrength >= 0 && properties.occlusionStrength <= 1)) throw new RangeError("occlusionStrength must be in [0, 1]");
|
||||
if (properties.alphaCutoff !== undefined && !(properties.alphaCutoff >= 0 && properties.alphaCutoff <= 1)) throw new RangeError("alphaCutoff must be in [0, 1]");
|
||||
if (properties.ior !== undefined) {
|
||||
const ior = finite(properties.ior, "ior");
|
||||
if (ior !== 0 && ior < 1) throw new RangeError("ior must be 0 or at least 1");
|
||||
setFloat(14, ior, "ior");
|
||||
setFloat(15, ior === 0 ? 1 : ((ior - 1) / (ior + 1)) ** 2, "ior");
|
||||
}
|
||||
this.#array.write(this.#key, words);
|
||||
return this;
|
||||
/** Conventional mesh transform over one SIMD-aligned shared row. */
|
||||
export class MeshHandle extends Handle {
|
||||
static async create(core, name = "mesh") {
|
||||
const mesh = new MeshHandle(await core.allocateRows({ name, rows: 1, stride: 64, format: "f32" }));
|
||||
mesh.transform = identity;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
#float(lane) { return wordToFloat(this.#array.read(this.#key)[lane]); }
|
||||
#floats(start, length) { return this.#array.read(this.#key).slice(start, start + length).map(wordToFloat); }
|
||||
get transform() { return this.state; }
|
||||
set transform(value) { this.state = value; }
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/** Shared render-data snapshot protocol consumed by the mesh-handle BVH worker. */
|
||||
export const SNAPSHOT = Object.freeze({
|
||||
MAGIC: 0x504e5359, BLOB_MAGIC: 0x32534452, VERSION: 1, BYTES: 256,
|
||||
SLOTS: 3, SLOT_BYTES: 64, SCHEMA: 2, INIT: 0, OPEN: 1, FAILED: 2,
|
||||
CLOSED: 3, FREE: 0, WRITING: 1, READY: 2, READING: 3,
|
||||
});
|
||||
export const STREAM_NAMES = ["meshSlot", "meshGeneration", "meshLocalMin", "meshLocalMax", "instanceSlot", "instanceGeneration", "instanceMeshSlot", "instanceMeshGeneration", "instanceModel", "instanceWorldMin", "instanceWorldMax", "instanceType"];
|
||||
const COMPONENTS = [1, 1, 3, 3, 1, 1, 1, 1, 16, 3, 3, 16];
|
||||
const SCALARS = [1, 1, 2, 2, 1, 1, 1, 1, 2, 2, 2, 1];
|
||||
const STRIDES = COMPONENTS.map(n => n * 4);
|
||||
|
||||
export class SnapshotProtocolError extends Error {
|
||||
constructor(code) { super(code); this.code = code; this.name = "SnapshotProtocolError"; }
|
||||
}
|
||||
|
||||
const bad = code => { throw new SnapshotProtocolError(code); };
|
||||
const add = (a, b) => { const n = a + b; if (!Number.isSafeInteger(n) || n > 0xffffffff) bad("BAD_RANGE"); return n; };
|
||||
const mul = (a, b) => { const n = a * b; if (!Number.isSafeInteger(n) || n > 0xffffffff) bad("BAD_RANGE"); return n; };
|
||||
|
||||
export class SnapshotReader {
|
||||
constructor(memory, controlPtr) {
|
||||
if (!memory || !(memory.buffer instanceof SharedArrayBuffer)) bad("BAD_MEMORY");
|
||||
if (!Number.isInteger(controlPtr) || controlPtr < 0 || controlPtr % 64 || add(controlPtr, 256) > memory.buffer.byteLength) bad("BAD_CONTROL_POINTER");
|
||||
this.memory = memory; this.controlPtr = controlPtr; this.buffer = null;
|
||||
this.refresh(); this.validateControl();
|
||||
}
|
||||
refresh() {
|
||||
if (this.buffer === this.memory.buffer) return;
|
||||
this.buffer = this.memory.buffer;
|
||||
if (add(this.controlPtr, 256) > this.buffer.byteLength) bad("BAD_CONTROL_POINTER");
|
||||
this.control = new Int32Array(this.buffer, this.controlPtr, 64);
|
||||
}
|
||||
validateControl() {
|
||||
const h = this.control;
|
||||
if ((Atomics.load(h, 0) >>> 0) !== SNAPSHOT.MAGIC) bad("BAD_MAGIC");
|
||||
if ((Atomics.load(h, 1) >>> 0) !== SNAPSHOT.VERSION) bad("BAD_VERSION");
|
||||
if ((Atomics.load(h, 2) >>> 0) !== SNAPSHOT.BYTES || (Atomics.load(h, 3) >>> 0) !== SNAPSHOT.SLOTS || (Atomics.load(h, 4) >>> 0) !== SNAPSHOT.SLOT_BYTES || (Atomics.load(h, 5) >>> 0) !== SNAPSHOT.SCHEMA) bad("BAD_LAYOUT");
|
||||
const lifecycle = Atomics.load(h, 6) >>> 0;
|
||||
if (lifecycle > SNAPSHOT.CLOSED) bad("BAD_LIFECYCLE");
|
||||
if ((Atomics.load(h, 15) >>> 0) !== 0) bad("BAD_RESERVED");
|
||||
}
|
||||
latest() {
|
||||
this.refresh(); this.validateControl();
|
||||
for (let tries = 0; tries < 16; tries++) {
|
||||
const a = Atomics.load(this.control, 7) >>> 0;
|
||||
if (a & 1) continue;
|
||||
const lifecycle = Atomics.load(this.control, 6) >>> 0;
|
||||
const value = { lifecycle, epoch: Atomics.load(this.control, 8) >>> 0, slot: Atomics.load(this.control, 9) >>> 0, revisionLo: Atomics.load(this.control, 10) >>> 0, revisionHi: Atomics.load(this.control, 11) >>> 0, wasmPages: Atomics.load(this.control, 12) >>> 0, layoutEpoch: Atomics.load(this.control, 13) >>> 0, error: Atomics.load(this.control, 14) >>> 0 };
|
||||
const b = Atomics.load(this.control, 7) >>> 0;
|
||||
if (a === b && !(b & 1)) {
|
||||
if (lifecycle === SNAPSHOT.FAILED) bad("SNAPSHOT_FAILED");
|
||||
if (lifecycle === SNAPSHOT.CLOSED) bad("SNAPSHOT_CLOSED");
|
||||
if (lifecycle !== SNAPSHOT.INIT && lifecycle !== SNAPSHOT.OPEN) bad("BAD_LIFECYCLE");
|
||||
if (value.wasmPages && value.wasmPages > this.buffer.byteLength / 65536) bad("BAD_WASM_PAGES");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
bad("UNSTABLE_CONTROL");
|
||||
}
|
||||
transaction(fn, expectedEpoch = 0) {
|
||||
this.refresh();
|
||||
const latest = this.latest();
|
||||
if (!latest.epoch || latest.slot >= SNAPSHOT.SLOTS || (expectedEpoch && latest.epoch !== expectedEpoch)) return null;
|
||||
let control = this.control;
|
||||
const base = 16 + latest.slot * 16;
|
||||
if (Atomics.compareExchange(control, base, SNAPSHOT.READY, SNAPSHOT.READING) !== SNAPSHOT.READY) return null;
|
||||
try {
|
||||
// memory.grow replaces memory.buffer even after the slot has been pinned.
|
||||
this.refresh(); control = this.control;
|
||||
const slot = Array.from({length: 16}, (_, i) => Atomics.load(control, base + i) >>> 0);
|
||||
if (slot[0] !== SNAPSHOT.READING || slot[1] !== latest.epoch || slot[2] !== latest.layoutEpoch || slot[5] !== latest.revisionLo || slot[6] !== latest.revisionHi || slot[9] !== SNAPSHOT.SCHEMA || slot[10] !== 64) return null;
|
||||
if (slot.slice(11).some(Boolean)) bad("BAD_SLOT_RESERVED");
|
||||
const ptr = slot[3], bytes = slot[4];
|
||||
if (ptr % 16 || bytes < 448 || bytes % 16 || add(ptr, bytes) > this.buffer.byteLength) bad("BAD_SLOT");
|
||||
const u32 = new Uint32Array(this.buffer, ptr, bytes / 4);
|
||||
if (u32[0] !== SNAPSHOT.BLOB_MAGIC || u32[1] !== SNAPSHOT.SCHEMA || u32[2] !== 64 || u32[3] !== bytes || u32[4] !== slot[1] || u32[5] !== slot[5] || u32[6] !== slot[6] || u32[7] !== 12 || u32[8] !== 64 || u32[9] !== 32 || u32[10] !== slot[7] || u32[11] !== slot[8] || u32[12] !== 0x01020304 || u32[13] !== 3) bad("BAD_BLOB");
|
||||
if (u32[14] || u32[15]) bad("BAD_BLOB_RESERVED");
|
||||
const ranges = [], streams = {};
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const d = 16 + i * 8, semantic = u32[d], scalar = u32[d + 1], offset = u32[d + 2], count = u32[d + 3], components = u32[d + 4], stride = u32[d + 5], width = u32[d + 6], reserved = u32[d + 7];
|
||||
const want = i < 4 ? slot[7] : slot[8];
|
||||
if (semantic !== i + 1 || scalar !== SCALARS[i] || count !== want || components !== COMPONENTS[i] || stride !== STRIDES[i] || width !== 4 || reserved || offset < 448 || offset % 16) bad("BAD_DESCRIPTOR");
|
||||
const end = add(offset, mul(stride, count));
|
||||
if (end > bytes) bad("BAD_DESCRIPTOR_RANGE");
|
||||
if (count) ranges.push([offset, end]);
|
||||
const Type = scalar === 2 ? Float32Array : Uint32Array;
|
||||
streams[STREAM_NAMES[i]] = new Type(this.buffer, add(ptr, offset), mul(count, components));
|
||||
}
|
||||
ranges.sort((a, b) => a[0] - b[0]);
|
||||
for (let i = 1; i < ranges.length; i++) if (ranges[i][0] < ranges[i - 1][1]) bad("OVERLAPPING_STREAMS");
|
||||
return fn(Object.freeze({epoch: slot[1], revisionLo: slot[5], revisionHi: slot[6], meshCount: slot[7], instanceCount: slot[8], streams: Object.freeze(streams)}));
|
||||
} finally {
|
||||
Atomics.store(control, base, SNAPSHOT.FREE); Atomics.notify(control, base);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,191 +1,34 @@
|
||||
/** Canonical DAG AST shared by every Yawn render-graph frontend. */
|
||||
const AST_KIND = "yawn-render-graph";
|
||||
const AST_VERSION = 1;
|
||||
const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_.-]*$/;
|
||||
export const GRAPH_AST_VERSION = 1;
|
||||
|
||||
export class GraphAstError extends TypeError {
|
||||
constructor(code, message = code) {
|
||||
super(message);
|
||||
this.name = "GraphAstError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const fail = (code, message) => {
|
||||
throw new GraphAstError(code, message);
|
||||
};
|
||||
const object = (value) =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
const identifier = (value) =>
|
||||
typeof value === "string" &&
|
||||
IDENTIFIER.test(value) &&
|
||||
new TextEncoder().encode(value).length <= 64;
|
||||
const finiteData = (value) =>
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
const data = value => value === null || typeof value === "string" || typeof value === "boolean" ||
|
||||
(typeof value === "number" && Number.isFinite(value)) ||
|
||||
(Array.isArray(value) && value.every(finiteData)) ||
|
||||
(object(value) && Object.values(value).every(finiteData));
|
||||
const clone = (value) => structuredClone(value);
|
||||
const freeze = (value) => {
|
||||
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
||||
Object.freeze(value);
|
||||
(Array.isArray(value) && value.every(data)) ||
|
||||
(value?.constructor === Object && Object.values(value).every(data));
|
||||
|
||||
function freeze(value) {
|
||||
if (value && typeof value === "object") {
|
||||
Object.values(value).forEach(freeze);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const u32 = (value, name) => {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 0xffffffff)
|
||||
fail("AST_U32", `${name} must be a uint32`);
|
||||
return value;
|
||||
};
|
||||
|
||||
function normalizePipelines(raw = {}) {
|
||||
if (!object(raw)) fail("AST_PIPELINES", "pipelines must be an object");
|
||||
const render = (raw.render ?? []).map((pipeline) => {
|
||||
if (!object(pipeline)) fail("AST_PIPELINE", "render pipeline must be an object");
|
||||
const result = {
|
||||
name: pipeline.name,
|
||||
shader: pipeline.shader,
|
||||
vertexEntry: pipeline.vertexEntry ?? "vs_main",
|
||||
fragmentEntry: pipeline.fragmentEntry ?? "fs_main",
|
||||
doubleSided: pipeline.doubleSided ?? false,
|
||||
material: pipeline.material ?? false,
|
||||
};
|
||||
if (
|
||||
!identifier(result.name) ||
|
||||
!identifier(result.vertexEntry) ||
|
||||
!identifier(result.fragmentEntry) ||
|
||||
typeof result.shader !== "string" ||
|
||||
typeof result.doubleSided !== "boolean" ||
|
||||
typeof result.material !== "boolean"
|
||||
)
|
||||
fail("AST_PIPELINE", "invalid render pipeline declaration");
|
||||
return result;
|
||||
});
|
||||
const compute = (raw.compute ?? []).map((pipeline) => {
|
||||
if (!object(pipeline)) fail("AST_PIPELINE", "compute pipeline must be an object");
|
||||
const result = {
|
||||
name: pipeline.name,
|
||||
shader: pipeline.shader,
|
||||
entry: pipeline.entry ?? "main",
|
||||
dispatch: Array.from(pipeline.dispatch ?? []),
|
||||
};
|
||||
if (
|
||||
!identifier(result.name) ||
|
||||
!identifier(result.entry) ||
|
||||
typeof result.shader !== "string" ||
|
||||
result.dispatch.length !== 3 ||
|
||||
result.dispatch.some((value) => u32(value, "dispatch") === 0)
|
||||
)
|
||||
fail("AST_PIPELINE", "invalid compute pipeline declaration");
|
||||
return result;
|
||||
});
|
||||
const names = new Set();
|
||||
for (const pipeline of [...render, ...compute]) {
|
||||
if (names.has(pipeline.name))
|
||||
fail("AST_PIPELINE_DUPLICATE", `duplicate pipeline '${pipeline.name}'`);
|
||||
names.add(pipeline.name);
|
||||
}
|
||||
return { render, compute };
|
||||
}
|
||||
|
||||
function normalizeNode(raw) {
|
||||
if (!object(raw) || !identifier(raw.id) || !object(raw.executor))
|
||||
fail("AST_NODE", "invalid node");
|
||||
if (raw.state !== "enabled" && raw.state !== "muted")
|
||||
fail("AST_NODE", "node state must be enabled or muted");
|
||||
if (!identifier(raw.executor.key)) fail("AST_NODE", "invalid executor key");
|
||||
const parameters = clone(raw.parameters ?? {});
|
||||
if (!finiteData(parameters)) fail("AST_DATA", "parameters must be finite data");
|
||||
if (!object(raw.inputs ?? {})) fail("AST_NODE", "inputs must be an object");
|
||||
const inputs = {};
|
||||
for (const name of Object.keys(raw.inputs ?? {}).sort()) {
|
||||
if (!identifier(name) || !Array.isArray(raw.inputs[name]))
|
||||
fail("AST_INPUT", "invalid node input");
|
||||
inputs[name] = raw.inputs[name].map((reference) => {
|
||||
if (!object(reference) || !identifier(reference.node) || !identifier(reference.socket))
|
||||
fail("AST_REFERENCE", "invalid DAG reference");
|
||||
return { node: reference.node, socket: reference.socket };
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: raw.id,
|
||||
state: raw.state,
|
||||
executor: { key: raw.executor.key, version: u32(raw.executor.version, "executor version") },
|
||||
parameters,
|
||||
inputs,
|
||||
};
|
||||
/** Creates the data-only AST consumed by every graph frontend. DAG edges are pass `after` IDs. */
|
||||
export function createGraphAst(graph) {
|
||||
if (!data(graph) || graph?.constructor !== Object || typeof graph.id !== "string" ||
|
||||
!Array.isArray(graph.passes)) throw new TypeError("GRAPH_AST");
|
||||
return freeze(structuredClone(graph));
|
||||
}
|
||||
|
||||
/** Creates the canonical in-memory graph AST shared by all authoring frontends. */
|
||||
export function createGraphAst({ kind, version, id, revision, pipelines = {}, nodes }) {
|
||||
if (kind !== undefined && kind !== AST_KIND) fail("AST_KIND", "invalid AST kind");
|
||||
if (version !== undefined && version !== AST_VERSION) fail("AST_VERSION", "unsupported AST version");
|
||||
if (!identifier(id)) fail("AST_ID", "invalid graph id");
|
||||
u32(revision, "revision");
|
||||
if (revision === 0) fail("AST_REVISION", "revision must be nonzero");
|
||||
if (!Array.isArray(nodes)) fail("AST_NODES", "nodes must be an array");
|
||||
const normalized = nodes.map(normalizeNode);
|
||||
const ids = new Set();
|
||||
for (const node of normalized) {
|
||||
if (ids.has(node.id)) fail("AST_NODE_DUPLICATE", `duplicate node '${node.id}'`);
|
||||
ids.add(node.id);
|
||||
}
|
||||
return freeze({
|
||||
kind: AST_KIND,
|
||||
version: AST_VERSION,
|
||||
id,
|
||||
revision,
|
||||
pipelines: normalizePipelines(pipelines),
|
||||
nodes: normalized,
|
||||
});
|
||||
}
|
||||
|
||||
export const reference = (node, socket) => {
|
||||
if (!identifier(node) || !identifier(socket)) fail("AST_REFERENCE", "invalid DAG reference");
|
||||
return Object.freeze({ node, socket });
|
||||
};
|
||||
|
||||
function data(value) {
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "boolean") return String(value);
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) fail("AST_DATA", "numbers must be finite");
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
function encode(value) {
|
||||
if (value === null || typeof value === "boolean" || typeof value === "number") return String(value);
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `(array${value.map((item) => ` ${data(item)}`).join("")})`;
|
||||
if (object(value))
|
||||
return `(object${Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => ` (field ${JSON.stringify(key)} ${data(value[key])})`)
|
||||
.join("")})`;
|
||||
fail("AST_DATA", "unsupported AST data value");
|
||||
if (Array.isArray(value)) return `(array${value.map(item => ` ${encode(item)}`).join("")})`;
|
||||
return `(object${Object.keys(value).sort().map(key =>
|
||||
` (field ${JSON.stringify(key)} ${encode(value[key])})`).join("")})`;
|
||||
}
|
||||
|
||||
/** Serializes a graph AST to the only graph wire format accepted by Yawn core. */
|
||||
export function serializeGraphAst(raw) {
|
||||
const graph = createGraphAst(raw);
|
||||
const nodes = graph.nodes
|
||||
.map((node) => {
|
||||
const inputs = Object.entries(node.inputs)
|
||||
.map(
|
||||
([name, references]) =>
|
||||
`\n (input ${JSON.stringify(name)}${references
|
||||
.map(
|
||||
(reference) =>
|
||||
` (ref ${JSON.stringify(reference.node)} ${JSON.stringify(reference.socket)})`,
|
||||
)
|
||||
.join("")})`,
|
||||
)
|
||||
.join("");
|
||||
return `\n (node ${JSON.stringify(node.id)} ${node.state}\n (executor ${JSON.stringify(node.executor.key)} ${node.executor.version})\n (params ${data(node.parameters)})\n (inputs${inputs})\n )`;
|
||||
})
|
||||
.join("");
|
||||
return `(yawn-graph ${AST_VERSION}\n (id ${JSON.stringify(graph.id)})\n (revision ${graph.revision})\n (pipelines ${data(graph.pipelines)})\n (nodes${nodes}))\n`;
|
||||
/** Serializes the AST as an S-expression; named pass references preserve DAG fan-out. */
|
||||
export function serializeGraphAst(graph) {
|
||||
return `(yawn-graph ${GRAPH_AST_VERSION} ${encode(createGraphAst(graph))})`;
|
||||
}
|
||||
|
||||
export const GRAPH_AST_KIND = AST_KIND;
|
||||
export const GRAPH_AST_VERSION = AST_VERSION;
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "FXNode frontend for Yawn render graphs",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.js",
|
||||
"./catalog": "./src/catalog.js"
|
||||
},
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@yawn/render-graph-ast": "0.1.0"
|
||||
}
|
||||
|
||||
@@ -1,601 +0,0 @@
|
||||
// Converts Yawn's FXNode authoring document into the canonical render-graph AST.
|
||||
import {
|
||||
CATALOG_VERSION,
|
||||
descriptors,
|
||||
GRAPH_ID,
|
||||
nodeDefinitions,
|
||||
socketTypes,
|
||||
} from "./catalog.js";
|
||||
import { createGraphAst } from "@yawn/render-graph-ast";
|
||||
|
||||
class AuthoringGraphError extends Error {
|
||||
constructor(code, details = {}) {
|
||||
super(code);
|
||||
this.name = "AuthoringGraphError";
|
||||
this.code = code;
|
||||
this.details = Object.freeze({ ...details });
|
||||
}
|
||||
}
|
||||
|
||||
const fail = (code, details) => {
|
||||
throw new AuthoringGraphError(code, details);
|
||||
};
|
||||
const object = (value) =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
const identifier = (value) =>
|
||||
typeof value === "string" &&
|
||||
/^[A-Za-z][A-Za-z0-9_.-]*$/.test(value) &&
|
||||
new TextEncoder().encode(value).length <= 64;
|
||||
const exactKeys = (value, keys) =>
|
||||
object(value) &&
|
||||
Object.keys(value).length === keys.length &&
|
||||
keys.every((key) => Object.hasOwn(value, key));
|
||||
const finiteJson = (value) =>
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
(typeof value === "number" && Number.isFinite(value)) ||
|
||||
(Array.isArray(value) && value.every(finiteJson)) ||
|
||||
(object(value) && Object.values(value).every(finiteJson));
|
||||
const canonical = (value) =>
|
||||
Array.isArray(value)
|
||||
? value.map(canonical)
|
||||
: object(value)
|
||||
? Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, canonical(value[key])]),
|
||||
)
|
||||
: value;
|
||||
const deepFreeze = (value) => {
|
||||
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const sourceMaps = new WeakMap();
|
||||
export const mapAuthoringDiagnostic = (ir, diagnostic) => {
|
||||
const details = diagnostic?.details;
|
||||
const path = [
|
||||
details?.path,
|
||||
diagnostic?.path,
|
||||
details?.field,
|
||||
diagnostic?.field,
|
||||
].find((value) => typeof value === "string");
|
||||
const map = sourceMaps.get(ir);
|
||||
let match;
|
||||
if (path && map)
|
||||
for (const key of Object.keys(map))
|
||||
if (
|
||||
(path === key ||
|
||||
path.startsWith(`${key}.`) ||
|
||||
path.startsWith(`${key}[`)) &&
|
||||
(!match || key.length > match.length)
|
||||
)
|
||||
match = key;
|
||||
return deepFreeze({
|
||||
name: diagnostic?.name,
|
||||
code: diagnostic?.code,
|
||||
message: details?.message ?? diagnostic?.message ?? diagnostic?.code,
|
||||
details: details === undefined ? undefined : structuredClone(details),
|
||||
path,
|
||||
source: match ? structuredClone(map[match]) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const mapValuePaths = (paths, path, source, value) => {
|
||||
paths[path] = source;
|
||||
if (Array.isArray(value))
|
||||
value.forEach((child, index) =>
|
||||
mapValuePaths(paths, `${path}[${index}]`, source, child),
|
||||
);
|
||||
else if (object(value))
|
||||
for (const key of Object.keys(value))
|
||||
mapValuePaths(paths, `${path}.${key}`, source, value[key]);
|
||||
};
|
||||
|
||||
function parameterValue(raw, schema, nodeId, key, semanticType) {
|
||||
if (!exactKeys(raw, ["kind", "value"]) || raw.kind !== schema.type)
|
||||
fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
|
||||
const value = raw.value;
|
||||
const bounded = (number) =>
|
||||
Number.isFinite(number) &&
|
||||
(schema.minimum === undefined || number >= schema.minimum) &&
|
||||
(schema.maximum === undefined || number <= schema.maximum);
|
||||
const valid =
|
||||
schema.type === "number"
|
||||
? bounded(value) && (!schema.integer || Number.isSafeInteger(value))
|
||||
: schema.type === "string"
|
||||
? typeof value === "string" &&
|
||||
(!schema.enum || schema.enum.includes(value))
|
||||
: schema.type === "boolean"
|
||||
? typeof value === "boolean"
|
||||
: schema.type === "vector" || schema.type === "color"
|
||||
? Array.isArray(value) &&
|
||||
value.length === (semanticType?.startsWith("vec") ? Number(semanticType.at(-1)) : (schema.type === "vector" ? 3 : 4)) &&
|
||||
value.every(bounded)
|
||||
: schema.type === "json" && finiteJson(value) && validSemanticValue(value, semanticType);
|
||||
if (!valid) fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
|
||||
return canonical(structuredClone(raw.value));
|
||||
}
|
||||
|
||||
function validSemanticValue(value, type) {
|
||||
if (!type) return true;
|
||||
const finiteVector = (candidate, size) =>
|
||||
Array.isArray(candidate) && candidate.length === size && candidate.every(Number.isFinite);
|
||||
const vector = /^vec([24])$/.exec(type);
|
||||
if (vector) return finiteVector(value, Number(vector[1]));
|
||||
if (type === "u32x16")
|
||||
return Array.isArray(value) && value.length === 16 &&
|
||||
value.every((word) => Number.isInteger(word) && word >= 0 && word <= 0xffffffff);
|
||||
if (type === "local_aabb")
|
||||
return exactKeys(value, ["min", "max"]) && finiteVector(value.min, 3) && finiteVector(value.max, 3);
|
||||
const match = /^mat([234])$/.exec(type);
|
||||
if (match) {
|
||||
const size = Number(match[1]);
|
||||
return Array.isArray(value) && value.length === size &&
|
||||
value.every((column) => finiteVector(column, size));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function adaptFxNodeSnapshot(raw, revision = 1, { pipelines = {} } = {}) {
|
||||
try {
|
||||
const rootKeys = [
|
||||
"graphId",
|
||||
"catalogVersion",
|
||||
"nodes",
|
||||
"links",
|
||||
"metadata",
|
||||
"version",
|
||||
];
|
||||
if (
|
||||
!exactKeys(raw, rootKeys) ||
|
||||
!Array.isArray(raw.nodes) ||
|
||||
!Array.isArray(raw.links) ||
|
||||
!object(raw.metadata) ||
|
||||
!finiteJson(raw.metadata)
|
||||
)
|
||||
fail("AUTHORING_SHAPE");
|
||||
if (raw.graphId !== GRAPH_ID || raw.catalogVersion !== CATALOG_VERSION)
|
||||
fail("AUTHORING_CATALOG");
|
||||
if (!Number.isSafeInteger(raw.version) || raw.version < 0)
|
||||
fail("AUTHORING_SHAPE");
|
||||
if (!Number.isInteger(revision) || revision < 1 || revision > 0xffffffff)
|
||||
fail("AUTHORING_REVISION");
|
||||
const nodes = new Map(),
|
||||
sockets = new Map(),
|
||||
paths = {};
|
||||
for (let ordinal = 0; ordinal < raw.nodes.length; ordinal++) {
|
||||
const n = raw.nodes[ordinal];
|
||||
if (!object(n) || !identifier(n.id)) fail("AUTHORING_ID", { id: n?.id });
|
||||
if (nodes.has(n.id)) fail("AUTHORING_ID_DUPLICATE", { id: n.id });
|
||||
const descriptor = descriptors[n.typeId],
|
||||
definition = nodeDefinitions[n.typeId];
|
||||
if (!descriptor)
|
||||
fail("AUTHORING_NODE_TYPE", { nodeId: n.id, typeId: n.typeId });
|
||||
const nodeKeys = [
|
||||
"id",
|
||||
"typeId",
|
||||
"typeVersion",
|
||||
"position",
|
||||
"size",
|
||||
"label",
|
||||
"parameters",
|
||||
"sockets",
|
||||
"muted",
|
||||
"collapsed",
|
||||
"extensions",
|
||||
"known",
|
||||
];
|
||||
if (Object.hasOwn(n, "parentId")) nodeKeys.push("parentId");
|
||||
if (
|
||||
!exactKeys(n, nodeKeys) ||
|
||||
n.known !== true ||
|
||||
n.typeVersion !== descriptor.version ||
|
||||
typeof n.muted !== "boolean" ||
|
||||
typeof n.collapsed !== "boolean" ||
|
||||
typeof n.label !== "string" ||
|
||||
!exactKeys(n.position, ["x", "y"]) ||
|
||||
!Number.isFinite(n.position.x) ||
|
||||
!Number.isFinite(n.position.y) ||
|
||||
!exactKeys(n.size, ["x", "y"]) ||
|
||||
!Number.isFinite(n.size.x) ||
|
||||
!Number.isFinite(n.size.y) ||
|
||||
n.size.x <= 0 ||
|
||||
n.size.y <= 0 ||
|
||||
(Object.hasOwn(n, "parentId") && !identifier(n.parentId)) ||
|
||||
!object(n.extensions) ||
|
||||
!finiteJson(n.extensions) ||
|
||||
!Array.isArray(n.sockets) ||
|
||||
!object(n.parameters)
|
||||
)
|
||||
fail("AUTHORING_NODE_INVALID", { nodeId: n.id });
|
||||
const parameterKeys = Object.keys(definition.parameters);
|
||||
if (
|
||||
Object.keys(n.parameters).length !== parameterKeys.length ||
|
||||
!parameterKeys.every((key) => Object.hasOwn(n.parameters, key))
|
||||
)
|
||||
fail("AUTHORING_PARAMETER_SET", { nodeId: n.id });
|
||||
const parameters = Object.fromEntries(
|
||||
parameterKeys.map((key) => [
|
||||
key,
|
||||
parameterValue(
|
||||
n.parameters[key],
|
||||
definition.parameters[key],
|
||||
n.id,
|
||||
key,
|
||||
),
|
||||
]),
|
||||
);
|
||||
if (n.typeId === "bloom_blur")
|
||||
parameters.direction =
|
||||
parameters.direction === "horizontal" ? [1, 0] : [0, 1];
|
||||
if (n.typeId === "frustum_cull") {
|
||||
parameters.camera = parameters.cameraSelection;
|
||||
delete parameters.cameraSelection;
|
||||
}
|
||||
if (n.typeId === "texture") {
|
||||
const extent =
|
||||
parameters.extentMode === "absolute"
|
||||
? {
|
||||
kind: "absolute",
|
||||
width: parameters.absoluteWidth,
|
||||
height: parameters.absoluteHeight,
|
||||
depthOrArrayLayers: parameters.depthOrArrayLayers,
|
||||
}
|
||||
: {
|
||||
kind: "surface_relative",
|
||||
width: {
|
||||
numerator: parameters.relativeWidthNumerator,
|
||||
denominator: parameters.relativeWidthDenominator,
|
||||
},
|
||||
height: {
|
||||
numerator: parameters.relativeHeightNumerator,
|
||||
denominator: parameters.relativeHeightDenominator,
|
||||
},
|
||||
depthOrArrayLayers: parameters.depthOrArrayLayers,
|
||||
};
|
||||
const flat = structuredClone(parameters);
|
||||
Object.keys(parameters).forEach((key) => delete parameters[key]);
|
||||
Object.assign(parameters, {
|
||||
residency: flat.residency,
|
||||
texture: {
|
||||
dimension: flat.dimension,
|
||||
format: flat.format,
|
||||
extent,
|
||||
mipLevelCount: flat.mipLevelCount,
|
||||
sampleCount: Number(flat.sampleCount),
|
||||
viewFormats: flat.viewFormat === "none" ? [] : [flat.viewFormat],
|
||||
},
|
||||
});
|
||||
}
|
||||
const expected = [
|
||||
...Object.keys(descriptor.inputs),
|
||||
...Object.keys(descriptor.outputs),
|
||||
];
|
||||
if (n.sockets.length !== expected.length)
|
||||
fail("AUTHORING_SOCKET_SET", { nodeId: n.id });
|
||||
for (const s of n.sockets) {
|
||||
if (!object(s) || !expected.includes(s.key) || sockets.has(s.id))
|
||||
fail("AUTHORING_SOCKET", { nodeId: n.id, socket: s?.key });
|
||||
const input = descriptor.inputs[s.key],
|
||||
socketDefinition = definition.sockets[s.key],
|
||||
direction = input ? "input" : "output",
|
||||
dataType = socketDefinition.type,
|
||||
socketKeys = [
|
||||
"id",
|
||||
"key",
|
||||
"label",
|
||||
"direction",
|
||||
"dataType",
|
||||
"accepts",
|
||||
"maxIncomingLinks",
|
||||
...(socketDefinition.value ? ["defaultValue"] : []),
|
||||
"visible",
|
||||
];
|
||||
if (
|
||||
!exactKeys(s, socketKeys) ||
|
||||
s.id !== `${n.id}:${s.key}` ||
|
||||
s.label !== socketDefinition.title ||
|
||||
s.direction !== direction ||
|
||||
s.dataType !== dataType ||
|
||||
!Array.isArray(s.accepts) ||
|
||||
s.accepts.length !==
|
||||
(direction === "input"
|
||||
? socketTypes[dataType].acceptsFrom.length
|
||||
: 0) ||
|
||||
!s.accepts.every(
|
||||
(v, i) =>
|
||||
v ===
|
||||
(direction === "input"
|
||||
? socketTypes[dataType].acceptsFrom[i]
|
||||
: undefined),
|
||||
) ||
|
||||
(socketDefinition.value
|
||||
? (() => {
|
||||
try {
|
||||
parameterValue(
|
||||
s.defaultValue,
|
||||
socketDefinition.value,
|
||||
n.id,
|
||||
s.key,
|
||||
input.accepted.types[0],
|
||||
);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})()
|
||||
: s.defaultValue !== undefined) ||
|
||||
s.visible !== socketDefinition.visible ||
|
||||
s.maxIncomingLinks !== socketDefinition.maxIncomingLinks
|
||||
)
|
||||
fail("AUTHORING_SOCKET", { nodeId: n.id, socket: s.key });
|
||||
sockets.set(s.id, {
|
||||
node: n.id,
|
||||
key: s.key,
|
||||
semanticName: (input ?? descriptor.outputs[s.key]).semanticName ?? s.key,
|
||||
direction,
|
||||
semanticType: input
|
||||
? input.accepted.types[0]
|
||||
: descriptor.outputs[s.key].type,
|
||||
authoringType: s.dataType,
|
||||
maxIncomingLinks: s.maxIncomingLinks,
|
||||
defaultValue: socketDefinition.value
|
||||
? structuredClone(s.defaultValue)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (new Set(n.sockets.map((s) => s.key)).size !== expected.length)
|
||||
fail("AUTHORING_SOCKET_SET", { nodeId: n.id });
|
||||
for (const key of Object.keys(descriptor.inputs)) {
|
||||
const authoredDefault = sockets.get(`${n.id}:${key}`).defaultValue;
|
||||
if (authoredDefault)
|
||||
parameters[`${descriptor.inputs[key].semanticName ?? key}Default`] = parameterValue(
|
||||
authoredDefault,
|
||||
definition.sockets[key].value,
|
||||
n.id,
|
||||
key,
|
||||
descriptor.inputs[key].accepted.types[0],
|
||||
);
|
||||
}
|
||||
nodes.set(n.id, {
|
||||
ordinal,
|
||||
value: {
|
||||
id: n.id,
|
||||
state: n.muted ? "muted" : "enabled",
|
||||
executor: { key: n.typeId, version: descriptor.version },
|
||||
parameters,
|
||||
inputs: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
const incoming = new Map(),
|
||||
linkIds = new Set(),
|
||||
linkSources = new Map();
|
||||
for (let ordinal = 0; ordinal < raw.links.length; ordinal++) {
|
||||
const link = raw.links[ordinal];
|
||||
if (
|
||||
!object(link) ||
|
||||
!identifier(link.id) ||
|
||||
linkIds.has(link.id) ||
|
||||
!exactKeys(link, [
|
||||
"id",
|
||||
"fromNodeId",
|
||||
"fromSocketId",
|
||||
"toNodeId",
|
||||
"toSocketId",
|
||||
"muted",
|
||||
"extensions",
|
||||
]) ||
|
||||
typeof link.muted !== "boolean" ||
|
||||
!object(link.extensions) ||
|
||||
!finiteJson(link.extensions)
|
||||
)
|
||||
fail("AUTHORING_LINK", { linkId: link?.id });
|
||||
linkIds.add(link.id);
|
||||
const from = sockets.get(link.fromSocketId),
|
||||
to = sockets.get(link.toSocketId);
|
||||
if (
|
||||
!from ||
|
||||
!to ||
|
||||
link.fromNodeId !== from.node ||
|
||||
link.toNodeId !== to.node ||
|
||||
from.direction !== "output" ||
|
||||
to.direction !== "input" ||
|
||||
(!link.muted &&
|
||||
(incoming.get(link.toSocketId) ?? 0) >= to.maxIncomingLinks)
|
||||
)
|
||||
fail(
|
||||
!link.muted &&
|
||||
(incoming.get(link.toSocketId) ?? 0) >=
|
||||
(to?.maxIncomingLinks ?? Infinity)
|
||||
? "AUTHORING_LINK_INCOMING"
|
||||
: "AUTHORING_LINK",
|
||||
!link.muted &&
|
||||
(incoming.get(link.toSocketId) ?? 0) >=
|
||||
(to?.maxIncomingLinks ?? Infinity)
|
||||
? { socketId: link.toSocketId }
|
||||
: { linkId: link.id },
|
||||
);
|
||||
const accepted =
|
||||
descriptors[nodes.get(to.node).value.executor.key].inputs[to.key]
|
||||
.accepted.types;
|
||||
const authoringAccepted =
|
||||
socketTypes[
|
||||
nodeDefinitions[nodes.get(to.node).value.executor.key].sockets[to.key]
|
||||
.type
|
||||
].acceptsFrom;
|
||||
if (
|
||||
!accepted.includes(from.semanticType) ||
|
||||
!authoringAccepted.includes(from.authoringType)
|
||||
)
|
||||
fail("AUTHORING_LINK_TYPE", { linkId: link.id });
|
||||
const linkSource = {
|
||||
kind: "link",
|
||||
linkId: link.id,
|
||||
fromNodeId: link.fromNodeId,
|
||||
fromSocketId: link.fromSocketId,
|
||||
toNodeId: link.toNodeId,
|
||||
toSocketId: link.toSocketId,
|
||||
muted: link.muted,
|
||||
nodeId: to.node,
|
||||
input: to.key,
|
||||
fromSocket: from.key,
|
||||
toSocket: to.key,
|
||||
};
|
||||
linkSources.set(link.id, linkSource);
|
||||
if (!link.muted) {
|
||||
incoming.set(link.toSocketId, (incoming.get(link.toSocketId) ?? 0) + 1);
|
||||
(nodes.get(to.node).value.inputs[to.semanticName] ??= []).push({
|
||||
node: from.node,
|
||||
socket: from.semanticName,
|
||||
});
|
||||
}
|
||||
}
|
||||
const ordered = [...nodes.values()].sort((a, b) =>
|
||||
a.value.id < b.value.id
|
||||
? -1
|
||||
: a.value.id > b.value.id
|
||||
? 1
|
||||
: a.ordinal - b.ordinal,
|
||||
);
|
||||
for (let wireOrdinal = 0; wireOrdinal < ordered.length; wireOrdinal++) {
|
||||
const item = ordered[wireOrdinal];
|
||||
item.value.inputs = Object.fromEntries(
|
||||
Object.keys(descriptors[item.value.executor.key].inputs)
|
||||
.map((key) => descriptors[item.value.executor.key].inputs[key].semanticName ?? key)
|
||||
.filter((semanticName) => Object.hasOwn(item.value.inputs, semanticName))
|
||||
.map((semanticName) => [semanticName, item.value.inputs[semanticName]]),
|
||||
);
|
||||
const base = `nodes[${wireOrdinal}]`;
|
||||
const nodeSource = { kind: "node", nodeId: item.value.id };
|
||||
paths[base] = nodeSource;
|
||||
for (const field of [
|
||||
"id",
|
||||
"state",
|
||||
"executor",
|
||||
"executor.key",
|
||||
"executor.version",
|
||||
])
|
||||
paths[`${base}.${field}`] = nodeSource;
|
||||
paths[`${base}.parameters`] = nodeSource;
|
||||
const parameterSource = (parameter) => ({
|
||||
kind: "parameter",
|
||||
nodeId: item.value.id,
|
||||
parameter,
|
||||
});
|
||||
if (item.value.executor.key === "texture") {
|
||||
const root = `${base}.parameters`;
|
||||
const texture = item.value.parameters.texture;
|
||||
paths[`${root}.residency`] = parameterSource("residency");
|
||||
paths[`${root}.texture`] = nodeSource;
|
||||
paths[`${root}.texture.dimension`] = parameterSource("dimension");
|
||||
paths[`${root}.texture.format`] = parameterSource("format");
|
||||
paths[`${root}.texture.extent`] = parameterSource("extentMode");
|
||||
paths[`${root}.texture.extent.kind`] = parameterSource("extentMode");
|
||||
paths[`${root}.texture.extent.depthOrArrayLayers`] =
|
||||
parameterSource("depthOrArrayLayers");
|
||||
if (texture.extent.kind === "absolute") {
|
||||
paths[`${root}.texture.extent.width`] =
|
||||
parameterSource("absoluteWidth");
|
||||
paths[`${root}.texture.extent.height`] =
|
||||
parameterSource("absoluteHeight");
|
||||
} else {
|
||||
paths[`${root}.texture.extent.width`] = parameterSource("extentMode");
|
||||
paths[`${root}.texture.extent.width.numerator`] = parameterSource(
|
||||
"relativeWidthNumerator",
|
||||
);
|
||||
paths[`${root}.texture.extent.width.denominator`] = parameterSource(
|
||||
"relativeWidthDenominator",
|
||||
);
|
||||
paths[`${root}.texture.extent.height`] =
|
||||
parameterSource("extentMode");
|
||||
paths[`${root}.texture.extent.height.numerator`] = parameterSource(
|
||||
"relativeHeightNumerator",
|
||||
);
|
||||
paths[`${root}.texture.extent.height.denominator`] = parameterSource(
|
||||
"relativeHeightDenominator",
|
||||
);
|
||||
}
|
||||
paths[`${root}.texture.mipLevelCount`] =
|
||||
parameterSource("mipLevelCount");
|
||||
paths[`${root}.texture.sampleCount`] = parameterSource("sampleCount");
|
||||
mapValuePaths(
|
||||
paths,
|
||||
`${root}.texture.viewFormats`,
|
||||
parameterSource("viewFormat"),
|
||||
texture.viewFormats,
|
||||
);
|
||||
} else
|
||||
for (const key of Object.keys(item.value.parameters))
|
||||
mapValuePaths(
|
||||
paths,
|
||||
`${base}.parameters.${key}`,
|
||||
key.endsWith("Default") && Object.hasOwn(descriptors[item.value.executor.key].inputs, key.slice(0, -7))
|
||||
? {
|
||||
kind: "input",
|
||||
nodeId: item.value.id,
|
||||
input: key.slice(0, -7),
|
||||
socketId: `${item.value.id}:${key.slice(0, -7)}`,
|
||||
unconnected: true,
|
||||
}
|
||||
: parameterSource(
|
||||
item.value.executor.key === "frustum_cull" && key === "camera"
|
||||
? "cameraSelection"
|
||||
: key,
|
||||
),
|
||||
item.value.parameters[key],
|
||||
);
|
||||
for (const key of Object.keys(
|
||||
descriptors[item.value.executor.key].inputs,
|
||||
)) {
|
||||
const links = raw.links.filter(
|
||||
(x) =>
|
||||
!x.muted &&
|
||||
x.toNodeId === item.value.id &&
|
||||
sockets.get(x.toSocketId)?.key === key,
|
||||
);
|
||||
const source = linkSources.get(links[0]?.id) ?? {
|
||||
kind: "input",
|
||||
nodeId: item.value.id,
|
||||
input: key,
|
||||
socketId: `${item.value.id}:${key}`,
|
||||
unconnected: true,
|
||||
};
|
||||
const semanticName = descriptors[item.value.executor.key].inputs[key].semanticName ?? key;
|
||||
paths[`${base}.inputs.${semanticName}`] = source;
|
||||
for (const [index, link] of links.entries()) {
|
||||
const linkSource = linkSources.get(link.id);
|
||||
paths[`${base}.inputs.${semanticName}[${index}]`] = linkSource;
|
||||
paths[`${base}.inputs.${semanticName}[${index}].node`] = linkSource;
|
||||
paths[`${base}.inputs.${semanticName}[${index}].socket`] = {
|
||||
kind: "socket",
|
||||
nodeId: link.fromNodeId,
|
||||
socketId: link.fromSocketId,
|
||||
socket: sockets.get(link.fromSocketId).key,
|
||||
linkId: link.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
paths[`${base}.inputs`] = nodeSource;
|
||||
}
|
||||
const ir = createGraphAst({
|
||||
id: GRAPH_ID,
|
||||
revision,
|
||||
pipelines,
|
||||
nodes: ordered.map((item) => item.value),
|
||||
});
|
||||
const graphSource = { kind: "graph", graphId: GRAPH_ID };
|
||||
for (const field of ["version", "id", "revision", "pipelines", "nodes"])
|
||||
paths[field] = graphSource;
|
||||
deepFreeze(paths);
|
||||
sourceMaps.set(ir, paths);
|
||||
return ir;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthoringGraphError) throw error;
|
||||
fail("AUTHORING_SHAPE");
|
||||
}
|
||||
}
|
||||
@@ -1,535 +0,0 @@
|
||||
// Yawn's FXNode contract catalog lives with this addon, not core or the example app.
|
||||
export const GRAPH_ID = "authored_gpu_culling";
|
||||
export const CATALOG_VERSION = 13;
|
||||
const exact = (type) => ({ kind: "exact", types: [type] });
|
||||
const i = (type, minimum = 1, authoringType, defaultPolicy = minimum ? "none" : "parameter_literal", maximum = 1) => ({
|
||||
accepted: typeof type === "string" ? exact(type) : type,
|
||||
cardinality: { minimum, maximum },
|
||||
...(authoringType ? { authoringType } : {}),
|
||||
defaultPolicy,
|
||||
});
|
||||
const o = (type, semanticName) => ({ type, ...(semanticName ? { semanticName } : {}) });
|
||||
const expression = (inputs, outputs) => ({
|
||||
version: 1,
|
||||
execution: "expression",
|
||||
inputs: Object.fromEntries(Object.entries(inputs).map(([name, type]) => [name, i(type, 0)])),
|
||||
outputs: Object.fromEntries(Object.entries(outputs).map(([name, type]) => [name, o(type)])),
|
||||
parameters: {},
|
||||
});
|
||||
const numbered = (prefix, count, type) =>
|
||||
Object.fromEntries(Array.from({ length: count }, (_, index) => [`${prefix}${index}`, type]));
|
||||
const expressionCatalog = {
|
||||
and: { ...expression({ inputs: "bool" }, { value: "bool" }), version: 2, inputs: { inputs: i("bool", 0, undefined, "none", 8) } },
|
||||
or: { ...expression({ inputs: "bool" }, { value: "bool" }), version: 2, inputs: { inputs: i("bool", 0, undefined, "none", 8) } },
|
||||
not: expression({ operand: "bool" }, { value: "bool" }),
|
||||
xor: { ...expression({ inputs: "bool" }, { value: "bool" }), version: 2, inputs: { inputs: i("bool", 0, undefined, "none", 8) } },
|
||||
xnor: { ...expression({ inputs: "bool" }, { value: "bool" }), version: 2, inputs: { inputs: i("bool", 0, undefined, "none", 8) } },
|
||||
greater_than_f32: expression({ left: "f32", right: "f32" }, { value: "bool" }),
|
||||
less_than_f32: expression({ left: "f32", right: "f32" }, { value: "bool" }),
|
||||
equals_f32: expression({ left: "f32", right: "f32" }, { value: "bool" }),
|
||||
greater_than_u32: expression({ left: "u32", right: "u32" }, { value: "bool" }),
|
||||
less_than_u32: expression({ left: "u32", right: "u32" }, { value: "bool" }),
|
||||
equals_u32: expression({ left: "u32", right: "u32" }, { value: "bool" }),
|
||||
separate_vec2: expression({ vector: "vec2" }, { x: "f32", y: "f32" }),
|
||||
combine_vec2: expression({ x: "f32", y: "f32" }, { vector: "vec2" }),
|
||||
separate_vec3: expression({ vector: "vec3" }, { x: "f32", y: "f32", z: "f32" }),
|
||||
combine_vec3: expression({ x: "f32", y: "f32", z: "f32" }, { vector: "vec3" }),
|
||||
separate_vec4: expression({ vector: "vec4" }, { x: "f32", y: "f32", z: "f32", w: "f32" }),
|
||||
combine_vec4: expression({ x: "f32", y: "f32", z: "f32", w: "f32" }, { vector: "vec4" }),
|
||||
separate_mat2: expression({ matrix: "mat2" }, numbered("column", 2, "vec2")),
|
||||
combine_mat2: expression(numbered("column", 2, "vec2"), { matrix: "mat2" }),
|
||||
separate_mat3: expression({ matrix: "mat3" }, numbered("column", 3, "vec3")),
|
||||
combine_mat3: expression(numbered("column", 3, "vec3"), { matrix: "mat3" }),
|
||||
separate_mat4: expression({ matrix: "mat4" }, numbered("column", 4, "vec4")),
|
||||
combine_mat4: expression(numbered("column", 4, "vec4"), { matrix: "mat4" }),
|
||||
separate_u32x16: expression({ value: "u32x16" }, numbered("word", 16, "u32")),
|
||||
combine_u32x16: expression(numbered("word", 16, "u32"), { value: "u32x16" }),
|
||||
separate_u32_bits: expression({ value: "u32" }, numbered("bit", 32, "bool")),
|
||||
combine_u32_bits: expression(numbered("bit", 32, "bool"), { value: "u32" }),
|
||||
separate_local_aabb: expression({ value: "local_aabb" }, { min: "vec3", max: "vec3" }),
|
||||
};
|
||||
const texture = {
|
||||
residency: "transient",
|
||||
texture: {
|
||||
dimension: "d2",
|
||||
format: "rgba16_float",
|
||||
extent: {
|
||||
kind: "surface_relative",
|
||||
width: { numerator: 1, denominator: 1 },
|
||||
height: { numerator: 1, denominator: 1 },
|
||||
depthOrArrayLayers: 1,
|
||||
},
|
||||
mipLevelCount: 1,
|
||||
sampleCount: 1,
|
||||
viewFormats: [],
|
||||
},
|
||||
};
|
||||
const rasterInputs = () => ({
|
||||
mesh: i("mesh_data"),
|
||||
predicate: i("bool", 0),
|
||||
"input.color": { ...i("texture", 0, undefined, "compiler_texture"), semanticName: "color" },
|
||||
"input.depth": { ...i("texture", 0, undefined, "compiler_texture"), semanticName: "depth" },
|
||||
});
|
||||
const rasterOutputs = () => ({ "output.color": o("texture", "color"), "output.depth": o("texture", "depth") });
|
||||
const rasterParameters = () => ({ depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1] });
|
||||
const raster = () => ({ version: 2, execution: "render", inputs: rasterInputs(), outputs: rasterOutputs(), parameters: rasterParameters() });
|
||||
export const NODE_TITLE_OVERRIDES = Object.freeze({
|
||||
ground_plane: "Ground Plane",
|
||||
gltf_standard: "glTF Standard",
|
||||
gltf_standard_double_sided: "glTF Standard — Double-Sided",
|
||||
});
|
||||
export const semanticCatalog = Object.freeze({
|
||||
mesh: {
|
||||
version: 2,
|
||||
execution: "source",
|
||||
inputs: {},
|
||||
outputs: {
|
||||
mesh: o("mesh_data"),
|
||||
type: o("u32x16"),
|
||||
localAabb: o("local_aabb"),
|
||||
},
|
||||
parameters: {},
|
||||
},
|
||||
texture: {
|
||||
version: 2,
|
||||
execution: "source",
|
||||
inputs: {},
|
||||
outputs: { texture: o("texture") },
|
||||
parameters: {
|
||||
residency: "transient",
|
||||
format: "rgba16_float",
|
||||
dimension: "d2",
|
||||
extentMode: "surface_relative",
|
||||
absoluteWidth: 1,
|
||||
absoluteHeight: 1,
|
||||
relativeWidthNumerator: 1,
|
||||
relativeWidthDenominator: 1,
|
||||
relativeHeightNumerator: 1,
|
||||
relativeHeightDenominator: 1,
|
||||
depthOrArrayLayers: 1,
|
||||
mipLevelCount: 1,
|
||||
sampleCount: "1",
|
||||
viewFormat: "none",
|
||||
},
|
||||
},
|
||||
frustum_cull: {
|
||||
version: 2,
|
||||
execution: "expression",
|
||||
inputs: {
|
||||
mesh: i("mesh_data"),
|
||||
localAabb: i("local_aabb"),
|
||||
},
|
||||
outputs: { isFrustumCulled: o("bool") },
|
||||
parameters: { cameraSelection: "active" },
|
||||
},
|
||||
ground_plane: raster(),
|
||||
gltf_standard: raster(),
|
||||
gltf_standard_double_sided: raster(),
|
||||
...expressionCatalog,
|
||||
fullscreen_copy: {
|
||||
version: 1,
|
||||
execution: "render",
|
||||
inputs: {
|
||||
source: i("texture"),
|
||||
colorTarget: i("texture"),
|
||||
},
|
||||
outputs: { color: o("texture") },
|
||||
parameters: {},
|
||||
},
|
||||
color_balance: {
|
||||
version: 1,
|
||||
execution: "render",
|
||||
inputs: { source: i("texture"), colorTarget: i("texture") },
|
||||
outputs: { color: o("texture") },
|
||||
parameters: {
|
||||
mode: "lift_gamma_gain", factor: 1,
|
||||
lift: 0, liftColor: [1, 1, 1, 1], gamma: 1, gammaColor: [1, 1, 1, 1], gain: 1, gainColor: [1, 1, 1, 1],
|
||||
offset: 0, offsetColor: [1, 1, 1, 1], power: 1, powerColor: [1, 1, 1, 1], slope: 1, slopeColor: [1, 1, 1, 1],
|
||||
},
|
||||
},
|
||||
exposure_contrast: {
|
||||
version: 1, execution: "render",
|
||||
inputs: { source: i("texture"), colorTarget: i("texture") }, outputs: { color: o("texture") },
|
||||
parameters: { exposureStops: 0, contrast: 1, pivot: 0.18, factor: 1 },
|
||||
},
|
||||
saturation: {
|
||||
version: 1, execution: "render",
|
||||
inputs: { source: i("texture"), colorTarget: i("texture") }, outputs: { color: o("texture") },
|
||||
parameters: { saturation: 1, factor: 1 },
|
||||
},
|
||||
channel_mixer: {
|
||||
version: 1, execution: "render",
|
||||
inputs: { source: i("texture"), colorTarget: i("texture") }, outputs: { color: o("texture") },
|
||||
parameters: { redOutput: [1, 0, 0], greenOutput: [0, 1, 0], blueOutput: [0, 0, 1], factor: 1 },
|
||||
},
|
||||
bloom_extract: {
|
||||
version: 1,
|
||||
execution: "render",
|
||||
inputs: {
|
||||
source: i("texture"),
|
||||
colorTarget: i("texture"),
|
||||
},
|
||||
outputs: { color: o("texture") },
|
||||
parameters: { threshold: 1, knee: 0.5 },
|
||||
},
|
||||
bloom_blur: {
|
||||
version: 1,
|
||||
execution: "render",
|
||||
inputs: {
|
||||
source: i("texture"),
|
||||
colorTarget: i("texture"),
|
||||
},
|
||||
outputs: { color: o("texture") },
|
||||
parameters: { direction: [1, 0], radius: 1 },
|
||||
},
|
||||
bloom_composite: {
|
||||
version: 1,
|
||||
execution: "render",
|
||||
inputs: {
|
||||
source: i("texture"),
|
||||
bloom: i("texture"),
|
||||
colorTarget: i("texture"),
|
||||
},
|
||||
outputs: { color: o("texture") },
|
||||
parameters: { intensity: 1 },
|
||||
},
|
||||
luminance_edge: {
|
||||
version: 1,
|
||||
execution: "render",
|
||||
inputs: {
|
||||
source: i("texture"),
|
||||
colorTarget: i("texture"),
|
||||
},
|
||||
outputs: { color: o("texture") },
|
||||
parameters: { strength: 2 },
|
||||
},
|
||||
frame_out: {
|
||||
version: 3,
|
||||
execution: "frame",
|
||||
inputs: { color: i("texture") },
|
||||
outputs: {},
|
||||
parameters: { surfaceFormat: "preferred", hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] },
|
||||
},
|
||||
});
|
||||
const socketColors = [
|
||||
"#d17c7c",
|
||||
"#d19e7c",
|
||||
"#d1c77c",
|
||||
"#9ed17c",
|
||||
"#7cd1a5",
|
||||
"#7ccbd1",
|
||||
"#7c98d1",
|
||||
"#a27cd1",
|
||||
"#d17cb8",
|
||||
];
|
||||
export const socketTypes = Object.fromEntries(
|
||||
[
|
||||
"texture",
|
||||
"mesh_data",
|
||||
"bool", "f32", "u32", "vec2", "vec3", "vec4",
|
||||
"mat2", "mat3", "mat4", "u32x16", "local_aabb",
|
||||
].map((type, index) => [
|
||||
type,
|
||||
{
|
||||
title: type.replaceAll("_", " "),
|
||||
color: socketColors[index % socketColors.length],
|
||||
acceptsFrom: [type],
|
||||
},
|
||||
]),
|
||||
);
|
||||
export const theme = {
|
||||
background: "#151820",
|
||||
grid: "#292e3a",
|
||||
frame: "#30343a80",
|
||||
frameHeader: "#59616c",
|
||||
body: "#292e39",
|
||||
control: "#24272b",
|
||||
controlFill: "#4775b8",
|
||||
controlEditing: "#181a1d",
|
||||
textSelection: "#4775b8",
|
||||
outline: "#0b0d12",
|
||||
text: "#edf1f7",
|
||||
muted: "#969eaa",
|
||||
shadow: "#00000088",
|
||||
nodeSelected: "#ff9f43",
|
||||
nodeActive: "#ffffff",
|
||||
unknownHeader: "#555b64",
|
||||
unknownSocket: "#999999",
|
||||
linkMuted: "#d94b4b",
|
||||
knifeMuted: "#e85b5b",
|
||||
emphasis: "#ffffff",
|
||||
focus: "#f5a623",
|
||||
editOutline: "#666a70",
|
||||
resize: "#8b8e95",
|
||||
muteOverlay: "#14141459",
|
||||
boxSelectionFill: "#f5a6231f",
|
||||
checkerLight: "#aaaaaa",
|
||||
checkerDark: "#777777",
|
||||
widgetBorder: "#111216",
|
||||
rampBorder: "#111111",
|
||||
resourceBackground: "#202228",
|
||||
};
|
||||
export const styles = {
|
||||
source: { header: "#3977a8" },
|
||||
compute: { header: "#725a9b" },
|
||||
expression: { header: "#725a9b" },
|
||||
cpu_preparation: { header: "#8a6d3b" },
|
||||
render: { header: "#426b43" },
|
||||
frame: { header: "#a75d37" },
|
||||
};
|
||||
const socket = (title, direction, type, value = null, capacity = 1) => ({
|
||||
title,
|
||||
direction,
|
||||
type,
|
||||
maxIncomingLinks: direction === "input" ? capacity : 0,
|
||||
visible: true,
|
||||
value,
|
||||
showValue: value !== null,
|
||||
});
|
||||
const tagged = (kind, value) => ({ kind, value: structuredClone(value) });
|
||||
const number = (value, minimum, maximum) => ({
|
||||
type: "number",
|
||||
default: tagged("number", value),
|
||||
...(minimum !== undefined ? { minimum } : {}),
|
||||
...(maximum !== undefined ? { maximum } : {}),
|
||||
});
|
||||
const enumeration = (value, values) => ({
|
||||
type: "string",
|
||||
default: tagged("string", value),
|
||||
enum: values,
|
||||
});
|
||||
const boolean = (value) => ({
|
||||
type: "boolean",
|
||||
default: tagged("boolean", value),
|
||||
});
|
||||
const color = (value, minimum = 0, maximum = 1) => ({
|
||||
type: "color",
|
||||
default: tagged("color", value),
|
||||
minimum,
|
||||
maximum,
|
||||
});
|
||||
const vector = (value, minimum, maximum) => ({
|
||||
type: "vector", default: tagged("vector", value),
|
||||
...(minimum !== undefined ? { minimum } : {}),
|
||||
...(maximum !== undefined ? { maximum } : {}),
|
||||
});
|
||||
const json = (value) => ({ type: "json", default: tagged("json", value) });
|
||||
const socketDefault = (type, value) => {
|
||||
if (type === "bool") return boolean(value);
|
||||
if (type === "f32") return number(value);
|
||||
if (type === "u32") return { ...number(value, 0, 0xffffffff), integer: true };
|
||||
if (type === "vec3") return vector(value);
|
||||
return json(value);
|
||||
};
|
||||
const zero = (type) => {
|
||||
if (type === "bool") return false;
|
||||
if (type === "f32" || type === "u32") return 0;
|
||||
if (/^vec[234]$/.test(type)) return Array(Number(type.at(-1))).fill(0);
|
||||
if (type === "u32x16") return Array(16).fill(0);
|
||||
if (type === "local_aabb") return { min: [0, 0, 0], max: [0, 0, 0] };
|
||||
const size = Number(type.at(-1));
|
||||
return Array.from({ length: size }, (_, column) =>
|
||||
Array.from({ length: size }, (_, row) => Number(column === row)));
|
||||
};
|
||||
const defaultForInput = (key, name, type) => {
|
||||
if (["ground_plane", "gltf_standard", "gltf_standard_double_sided"].includes(key) && name === "predicate") return true;
|
||||
if (key === "and") return true;
|
||||
if (/^combine_mat[234]$/.test(key)) {
|
||||
const index = Number(name.replace("column", ""));
|
||||
return zero(type).map((_, row) => Number(index === row));
|
||||
}
|
||||
return zero(type);
|
||||
};
|
||||
const parameterSchemas = {
|
||||
texture: {
|
||||
residency: enumeration("transient", ["transient", "persistent"]),
|
||||
format: enumeration("rgba16_float", [
|
||||
"rgba8_unorm",
|
||||
"rgba8_unorm_srgb",
|
||||
"bgra8_unorm",
|
||||
"bgra8_unorm_srgb",
|
||||
"rgba16_float",
|
||||
"r32_float",
|
||||
"depth32_float",
|
||||
]),
|
||||
dimension: enumeration("d2", ["d1", "d2", "d3"]),
|
||||
extentMode: enumeration("surface_relative", [
|
||||
"surface_relative",
|
||||
"absolute",
|
||||
]),
|
||||
absoluteWidth: { ...number(1, 1, 0xffffffff), integer: true },
|
||||
absoluteHeight: { ...number(1, 1, 0xffffffff), integer: true },
|
||||
relativeWidthNumerator: { ...number(1, 1, 0xffffffff), integer: true },
|
||||
relativeWidthDenominator: { ...number(1, 1, 0xffffffff), integer: true },
|
||||
relativeHeightNumerator: { ...number(1, 1, 0xffffffff), integer: true },
|
||||
relativeHeightDenominator: { ...number(1, 1, 0xffffffff), integer: true },
|
||||
depthOrArrayLayers: { ...number(1, 1, 0xffffffff), integer: true },
|
||||
mipLevelCount: { ...number(1, 1, 0xffffffff), integer: true },
|
||||
sampleCount: enumeration("1", ["1", "4"]),
|
||||
viewFormat: enumeration("none", [
|
||||
"none",
|
||||
"rgba8_unorm",
|
||||
"rgba8_unorm_srgb",
|
||||
"bgra8_unorm",
|
||||
"bgra8_unorm_srgb",
|
||||
"rgba16_float",
|
||||
"r32_float",
|
||||
"depth32_float",
|
||||
]),
|
||||
},
|
||||
mesh: {},
|
||||
frustum_cull: { cameraSelection: enumeration("active", ["active"]) },
|
||||
ground_plane: {
|
||||
depthCompare: enumeration("less_equal", [
|
||||
"never",
|
||||
"less",
|
||||
"equal",
|
||||
"less_equal",
|
||||
"greater",
|
||||
"not_equal",
|
||||
"greater_equal",
|
||||
"always",
|
||||
]),
|
||||
depthWriteEnabled: boolean(true),
|
||||
clearDepth: number(1, 0, 1),
|
||||
clearColor: color([0.015, 0.02, 0.03, 1]),
|
||||
},
|
||||
fullscreen_copy: {},
|
||||
color_balance: {
|
||||
mode: enumeration("lift_gamma_gain", ["lift_gamma_gain", "offset_power_slope"]), factor: number(1, 0, 1),
|
||||
lift: number(0, -1, 1), liftColor: color([1, 1, 1, 1], 0, 4), gamma: number(1, 0.01, 4), gammaColor: color([1, 1, 1, 1], 0, 4), gain: number(1, 0, 4), gainColor: color([1, 1, 1, 1], 0, 4),
|
||||
offset: number(0, -1, 1), offsetColor: color([1, 1, 1, 1], 0, 2), power: number(1, 0.01, 4), powerColor: color([1, 1, 1, 1], 0, 4), slope: number(1, 0, 4), slopeColor: color([1, 1, 1, 1], 0, 4),
|
||||
},
|
||||
exposure_contrast: { exposureStops: number(0, -10, 10), contrast: number(1, 0.01, 4), pivot: number(0.18, 0.001, 4), factor: number(1, 0, 1) },
|
||||
saturation: { saturation: number(1, 0, 4), factor: number(1, 0, 1) },
|
||||
channel_mixer: { redOutput: vector([1, 0, 0], -2, 2), greenOutput: vector([0, 1, 0], -2, 2), blueOutput: vector([0, 0, 1], -2, 2), factor: number(1, 0, 1) },
|
||||
bloom_extract: { threshold: number(1, 0, 64), knee: number(0.5, 0, 1) },
|
||||
bloom_blur: {
|
||||
direction: enumeration("horizontal", ["horizontal", "vertical"]),
|
||||
radius: number(1, 1, 16),
|
||||
},
|
||||
bloom_composite: { intensity: number(1, 0, 16) },
|
||||
luminance_edge: { strength: number(2, 0, 16) },
|
||||
frame_out: {
|
||||
surfaceFormat: enumeration("preferred", ["preferred", "rgba8_unorm", "bgra8_unorm", "rgba16_float"]),
|
||||
hdrEnabled: boolean(true),
|
||||
toneMapper: enumeration("aces", ["aces", "reinhard", "none"]),
|
||||
exposureStops: number(0, -10, 10),
|
||||
outputTransfer: enumeration("srgb", ["srgb", "linear"]),
|
||||
scaleMode: enumeration("stretch", ["stretch", "contain", "cover"]),
|
||||
filter: enumeration("linear", ["linear", "nearest"]),
|
||||
backgroundColor: color([0, 0, 0, 1]),
|
||||
},
|
||||
};
|
||||
for (const key of Object.keys(expressionCatalog)) parameterSchemas[key] = {};
|
||||
parameterSchemas.gltf_standard = structuredClone(parameterSchemas.ground_plane);
|
||||
parameterSchemas.gltf_standard_double_sided = structuredClone(parameterSchemas.ground_plane);
|
||||
export const nodeDefinitions = Object.fromEntries(
|
||||
Object.entries(semanticCatalog).map(([key, c]) => {
|
||||
const sockets = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(c.inputs).map(([n, v]) => [
|
||||
n,
|
||||
socket(
|
||||
v.semanticName ?? n,
|
||||
"input",
|
||||
v.authoringType ?? v.accepted.types[0],
|
||||
v.defaultPolicy === "parameter_literal" ? socketDefault(v.accepted.types[0], defaultForInput(key, n, v.accepted.types[0])) : null,
|
||||
v.cardinality.maximum,
|
||||
),
|
||||
]),
|
||||
),
|
||||
...Object.fromEntries(
|
||||
Object.entries(c.outputs).map(([n, v]) => [
|
||||
n,
|
||||
socket(v.semanticName ?? n, "output", v.authoringType ?? v.type),
|
||||
]),
|
||||
),
|
||||
},
|
||||
parameters = parameterSchemas[key];
|
||||
if (
|
||||
!parameters ||
|
||||
Object.keys(parameters).length !== Object.keys(c.parameters).length ||
|
||||
!Object.keys(c.parameters).every((name) =>
|
||||
Object.hasOwn(parameters, name),
|
||||
)
|
||||
)
|
||||
throw new Error(`parameter schema mismatch for ${key}`);
|
||||
return [
|
||||
key,
|
||||
{
|
||||
version: c.version,
|
||||
title: NODE_TITLE_OVERRIDES[key] ?? key.replaceAll("_", " "),
|
||||
behavior: "standard",
|
||||
style: c.execution,
|
||||
parameters,
|
||||
sockets,
|
||||
ui: [
|
||||
...Object.keys(parameters).map((parameter) => ({
|
||||
kind: "parameter",
|
||||
parameter,
|
||||
...(key === "frustum_cull" && parameter === "cameraSelection"
|
||||
? { title: "Camera" }
|
||||
: {}),
|
||||
})),
|
||||
...Object.keys(sockets).map((socket) => ({ kind: "socket", socket })),
|
||||
],
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
nodeDefinitions.mesh.sockets.localAabb.title = "Local AABB";
|
||||
for (const key of Object.keys(NODE_TITLE_OVERRIDES)) {
|
||||
for (const item of nodeDefinitions[key].ui) {
|
||||
if (item.parameter === "clearColor") item.title = "Initial Color";
|
||||
if (item.parameter === "clearDepth") item.title = "Initial Depth";
|
||||
}
|
||||
}
|
||||
nodeDefinitions.color_balance.ui = [
|
||||
{ kind: "parameter", parameter: "mode" },
|
||||
{ kind: "widget", widget: "grading-wheels", bindings: [
|
||||
{ title: "Lift", scalar: "lift", color: "liftColor" }, { title: "Gamma", scalar: "gamma", color: "gammaColor" }, { title: "Gain", scalar: "gain", color: "gainColor" },
|
||||
], visibleWhen: { parameter: "mode", equals: "lift_gamma_gain" } },
|
||||
{ kind: "widget", widget: "grading-wheels", bindings: [
|
||||
{ title: "Offset", scalar: "offset", color: "offsetColor" }, { title: "Power", scalar: "power", color: "powerColor" }, { title: "Slope", scalar: "slope", color: "slopeColor" },
|
||||
], visibleWhen: { parameter: "mode", equals: "offset_power_slope" } },
|
||||
{ kind: "parameter", parameter: "factor" },
|
||||
{ kind: "socket", socket: "source" }, { kind: "socket", socket: "colorTarget" }, { kind: "socket", socket: "color" },
|
||||
];
|
||||
nodeDefinitions.frame_out.ui = [
|
||||
{ kind: "text", variant: "section", title: "Canvas Presentation" },
|
||||
{ kind: "parameter", parameter: "surfaceFormat", title: "Surface Format" },
|
||||
{ kind: "text", variant: "section", title: "Display Transform" },
|
||||
{ kind: "parameter", parameter: "hdrEnabled", title: "HDR" },
|
||||
{ kind: "parameter", parameter: "toneMapper", title: "Tone Mapper", visibleWhen: { parameter: "hdrEnabled", equals: true } },
|
||||
{ kind: "parameter", parameter: "exposureStops", title: "Exposure", visibleWhen: { parameter: "hdrEnabled", equals: true } },
|
||||
{ kind: "parameter", parameter: "outputTransfer", title: "Transfer" },
|
||||
{ kind: "parameter", parameter: "scaleMode", title: "Scale" },
|
||||
{ kind: "parameter", parameter: "filter" },
|
||||
{ kind: "parameter", parameter: "backgroundColor", title: "Background", visibleWhen: { parameter: "scaleMode", equals: "contain" } },
|
||||
{ kind: "socket", socket: "color" },
|
||||
];
|
||||
export const fxNodeComposition = Object.freeze({
|
||||
schemaVersion: 2,
|
||||
id: "yawn.render-graph",
|
||||
version: CATALOG_VERSION,
|
||||
compatibility: { wildcardInputTypes: [] },
|
||||
socketTypes,
|
||||
nodeStyles: styles,
|
||||
resources: {},
|
||||
theme,
|
||||
nodes: nodeDefinitions,
|
||||
});
|
||||
export const descriptors = Object.fromEntries(
|
||||
Object.entries(semanticCatalog).map(([key, c]) => [
|
||||
key,
|
||||
{
|
||||
version: c.version,
|
||||
inputs: c.inputs,
|
||||
outputs: c.outputs,
|
||||
parameters: c.parameters,
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -1,6 +1,18 @@
|
||||
// FXNode is an optional authoring frontend. Its exporter is intentionally the only
|
||||
// FXNode-aware code that feeds the canonical AST package.
|
||||
export {
|
||||
adaptFxNodeSnapshot,
|
||||
mapAuthoringDiagnostic,
|
||||
} from "./adapter.js";
|
||||
import { createGraphAst } from "@yawn/render-graph-ast";
|
||||
|
||||
/** Exports FXNode nodes with a `pass` payload and links as the canonical pass DAG. */
|
||||
export function adaptFxNodeSnapshot(snapshot, { pipelines = {}, resources = {} } = {}) {
|
||||
if (!Array.isArray(snapshot?.nodes) || !Array.isArray(snapshot?.links)) throw new TypeError("FXNODE_GRAPH");
|
||||
const passes = snapshot.nodes.map(node => {
|
||||
if (!node?.id || !node.pass) throw new TypeError("FXNODE_PASS");
|
||||
return { ...structuredClone(node.pass), id: node.id, after: [...(node.pass.after ?? [])] };
|
||||
});
|
||||
const byId = new Map(passes.map(pass => [pass.id, pass]));
|
||||
for (const link of snapshot.links) {
|
||||
const source = link.fromNodeId ?? link.from?.node;
|
||||
const target = link.toNodeId ?? link.to?.node;
|
||||
if (!byId.has(source) || !byId.has(target)) throw new TypeError("FXNODE_LINK");
|
||||
if (!byId.get(target).after.includes(source)) byId.get(target).after.push(source);
|
||||
}
|
||||
return createGraphAst({ id: snapshot.graphId ?? "fxnode", pipelines, resources, passes });
|
||||
}
|
||||
|
||||
@@ -1,67 +1,12 @@
|
||||
import {
|
||||
createGraphAst,
|
||||
reference,
|
||||
serializeGraphAst,
|
||||
} from "@yawn/render-graph-ast";
|
||||
import { createGraphAst, serializeGraphAst } from "@yawn/render-graph-ast";
|
||||
|
||||
/** Compiles a plain JavaScript object description into the canonical graph AST. */
|
||||
export const graphFromObject = (description) => createGraphAst(description);
|
||||
/** Converts a plain JavaScript object into the canonical render-graph AST. */
|
||||
export const graphFromObject = graph => createGraphAst(graph);
|
||||
|
||||
/** Small mutable authoring facade; `ast()` returns an immutable canonical AST. */
|
||||
export class RenderGraph {
|
||||
#id;
|
||||
#revision;
|
||||
#nodes = [];
|
||||
#render = [];
|
||||
#compute = [];
|
||||
|
||||
constructor(id, revision = 1) {
|
||||
this.#id = id;
|
||||
this.#revision = revision;
|
||||
}
|
||||
|
||||
renderPipeline(declaration) {
|
||||
this.#render.push(structuredClone(declaration));
|
||||
return this;
|
||||
}
|
||||
|
||||
computePipeline(declaration) {
|
||||
this.#compute.push(structuredClone(declaration));
|
||||
return this;
|
||||
}
|
||||
|
||||
node(id, executor, { version = 1, parameters = {}, inputs = {}, state = "enabled" } = {}) {
|
||||
this.#nodes.push({
|
||||
id,
|
||||
state,
|
||||
executor: { key: executor, version },
|
||||
parameters: structuredClone(parameters),
|
||||
inputs: structuredClone(inputs),
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
ast() {
|
||||
return createGraphAst({
|
||||
id: this.#id,
|
||||
revision: this.#revision,
|
||||
pipelines: { render: this.#render, compute: this.#compute },
|
||||
nodes: this.#nodes,
|
||||
});
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return serializeGraphAst(this.ast());
|
||||
}
|
||||
|
||||
load(core) {
|
||||
return core.compileGraph(this.serialize());
|
||||
}
|
||||
/** Serializes a JSO graph and asks Yawn Core to prepare and activate its loadout. */
|
||||
export function loadGraph(core, graph) {
|
||||
if (!core?.loadGraph) throw new TypeError("core must be a YawnCore instance");
|
||||
return core.loadGraph(serializeGraphAst(graphFromObject(graph)));
|
||||
}
|
||||
|
||||
/** Canonicalizes a JSO/AST and sends its S-expression wire form to Yawn core. */
|
||||
export function loadGraph(core, description) {
|
||||
return core.compileGraph(serializeGraphAst(graphFromObject(description)));
|
||||
}
|
||||
|
||||
export { reference as ref, serializeGraphAst };
|
||||
export { serializeGraphAst };
|
||||
|
||||
Reference in New Issue
Block a user