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
-13
@@ -1,16 +1,4 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
command -v npm >/dev/null
|
||||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
|
||||||
|
|
||||||
for command in node npm cargo wasm-pack rsw; do
|
|
||||||
if ! command -v "$command" >/dev/null 2>&1; then
|
|
||||||
echo "Missing required command after orb resume: $command" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Ensuring orb development services are running..."
|
|
||||||
amp orb services ensure
|
amp orb services ensure
|
||||||
|
|
||||||
echo "Orb environment ready."
|
|
||||||
|
|||||||
-124
@@ -1,128 +1,4 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
NODE_VERSION="22.23.1"
|
|
||||||
WASM_PACK_VERSION="0.15.0"
|
|
||||||
RSW_VERSION="0.8.0"
|
|
||||||
|
|
||||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
|
||||||
mkdir -p "$HOME/.local/bin" "$HOME/.cargo/bin"
|
|
||||||
|
|
||||||
missing_packages=()
|
|
||||||
for package in libvulkan1 mesa-vulkan-drivers xauth xvfb; do
|
|
||||||
if ! dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -Fq "install ok installed"; then
|
|
||||||
missing_packages+=("$package")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if ((${#missing_packages[@]})); then
|
|
||||||
echo "Installing WebGPU runtime dependencies..."
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y "${missing_packages[@]}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
install_node() {
|
|
||||||
local machine node_arch archive install_dir tmp_dir
|
|
||||||
machine="$(uname -m)"
|
|
||||||
case "$machine" in
|
|
||||||
x86_64) node_arch="x64" ;;
|
|
||||||
aarch64) node_arch="arm64" ;;
|
|
||||||
*)
|
|
||||||
echo "Unsupported architecture for Node.js: $machine" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
archive="node-v${NODE_VERSION}-linux-${node_arch}.tar.xz"
|
|
||||||
install_dir="$HOME/.local/node-v${NODE_VERSION}-linux-${node_arch}"
|
|
||||||
if [[ ! -x "$install_dir/bin/node" ]]; then
|
|
||||||
echo "Installing Node.js v${NODE_VERSION}..."
|
|
||||||
tmp_dir="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$tmp_dir"' RETURN
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
"https://nodejs.org/dist/v${NODE_VERSION}/${archive}" \
|
|
||||||
--output "$tmp_dir/$archive"
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
"https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" \
|
|
||||||
--output "$tmp_dir/SHASUMS256.txt"
|
|
||||||
(
|
|
||||||
cd "$tmp_dir"
|
|
||||||
grep " ${archive}$" SHASUMS256.txt | sha256sum --check --strict
|
|
||||||
)
|
|
||||||
tar -xJf "$tmp_dir/$archive" -C "$HOME/.local"
|
|
||||||
rm -rf "$tmp_dir"
|
|
||||||
trap - RETURN
|
|
||||||
fi
|
|
||||||
|
|
||||||
for command in node npm npx corepack; do
|
|
||||||
ln -sfn "$install_dir/bin/$command" "$HOME/.local/bin/$command"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
install_wasm_pack() {
|
|
||||||
local machine target archive tmp_dir
|
|
||||||
machine="$(uname -m)"
|
|
||||||
case "$machine" in
|
|
||||||
x86_64) target="x86_64-unknown-linux-musl" ;;
|
|
||||||
aarch64) target="aarch64-unknown-linux-musl" ;;
|
|
||||||
*)
|
|
||||||
echo "Unsupported architecture for wasm-pack: $machine" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if ! wasm-pack --version 2>/dev/null | grep -Fq "${WASM_PACK_VERSION}"; then
|
|
||||||
echo "Installing wasm-pack ${WASM_PACK_VERSION}..."
|
|
||||||
archive="wasm-pack-v${WASM_PACK_VERSION}-${target}.tar.gz"
|
|
||||||
tmp_dir="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$tmp_dir"' RETURN
|
|
||||||
curl --fail --location --silent --show-error \
|
|
||||||
"https://github.com/wasm-bindgen/wasm-pack/releases/download/v${WASM_PACK_VERSION}/${archive}" \
|
|
||||||
--output "$tmp_dir/$archive"
|
|
||||||
tar -xzf "$tmp_dir/$archive" -C "$tmp_dir"
|
|
||||||
install -m 0755 \
|
|
||||||
"$tmp_dir/wasm-pack-v${WASM_PACK_VERSION}-${target}/wasm-pack" \
|
|
||||||
"$HOME/.cargo/bin/wasm-pack"
|
|
||||||
rm -rf "$tmp_dir"
|
|
||||||
trap - RETURN
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
install_node
|
|
||||||
|
|
||||||
if ! command -v rustup >/dev/null 2>&1; then
|
|
||||||
echo "Installing rustup..."
|
|
||||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error \
|
|
||||||
https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path \
|
|
||||||
--default-toolchain none
|
|
||||||
fi
|
|
||||||
source "$HOME/.cargo/env"
|
|
||||||
|
|
||||||
toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)"
|
|
||||||
echo "Installing Rust toolchain ${toolchain}..."
|
|
||||||
rustup toolchain install "$toolchain" --profile minimal \
|
|
||||||
--component rust-src \
|
|
||||||
--component rustfmt \
|
|
||||||
--target wasm32-unknown-unknown
|
|
||||||
|
|
||||||
install_wasm_pack
|
|
||||||
|
|
||||||
if ! rsw --version 2>/dev/null | grep -Fq "${RSW_VERSION}"; then
|
|
||||||
echo "Installing rsw ${RSW_VERSION}..."
|
|
||||||
cargo install rsw --version "$RSW_VERSION" --locked
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Amp shells include ~/.local/bin, while setup's PATH changes do not persist.
|
|
||||||
for command in cargo rustc rustdoc rustfmt rustup wasm-pack rsw; do
|
|
||||||
ln -sfn "$HOME/.cargo/bin/$command" "$HOME/.local/bin/$command"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Installing JavaScript dependencies..."
|
|
||||||
npm ci
|
npm ci
|
||||||
|
|
||||||
echo "Building the application..."
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
echo "Starting orb development services..."
|
|
||||||
amp orb services ensure
|
amp orb services ensure
|
||||||
|
|
||||||
echo "Orb setup complete."
|
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
yawn-examples:
|
yawn-docs:
|
||||||
command: npm run examples
|
command: npm start
|
||||||
portal:
|
portal:
|
||||||
title: Yawn docs and playgrounds
|
title: Yawn
|
||||||
description: VitePress package tutorials and isolated WebGPU playgrounds with hot reload.
|
description: Documentation and minimal WebGPU playground.
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
[build]
|
|
||||||
target = "wasm32-unknown-unknown"
|
|
||||||
rustflags = [
|
|
||||||
'-Ctarget-feature=+atomics,+bulk-memory,+mutable-globals',
|
|
||||||
'-Clink-args=--shared-memory',
|
|
||||||
'-Clink-args=--max-memory=1073741824',
|
|
||||||
'-Clink-args=--import-memory',
|
|
||||||
'-Clink-args=--export=__wasm_init_tls',
|
|
||||||
'-Clink-args=--export=__tls_size',
|
|
||||||
'-Clink-args=--export=__tls_align',
|
|
||||||
'-Clink-args=--export=__tls_base',
|
|
||||||
]
|
|
||||||
|
|
||||||
[unstable]
|
|
||||||
build-std = ['std', 'panic_abort']
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
static/sponza.glb filter=lfs diff=lfs merge=lfs -text
|
|
||||||
+1
-20
@@ -23,30 +23,11 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
# Generated by Cargo
|
|
||||||
# will have compiled files and executables
|
|
||||||
debug/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
|
||||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
|
||||||
# Cargo.lock
|
|
||||||
|
|
||||||
# These are backup files generated by rustfmt
|
|
||||||
**/*.rs.bk
|
|
||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
|
||||||
*.pdb
|
|
||||||
|
|
||||||
/pkg
|
|
||||||
/wasm-pack.log
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# WASM build artifacts
|
|
||||||
.rsw/
|
|
||||||
static/level-editor/
|
|
||||||
docs/.vitepress/cache/
|
docs/.vitepress/cache/
|
||||||
|
docs/.vitepress/dist/
|
||||||
|
|
||||||
# Amp runtime artifacts
|
# Amp runtime artifacts
|
||||||
.amp/in/
|
.amp/in/
|
||||||
|
|||||||
Generated
-1392
File diff suppressed because it is too large
Load Diff
-25
@@ -1,25 +0,0 @@
|
|||||||
[workspace]
|
|
||||||
members = ["renderer"]
|
|
||||||
resolver = "2"
|
|
||||||
|
|
||||||
[workspace.dependencies]
|
|
||||||
wasm-bindgen = { version = "0.2.100", features = ["enable-interning"] }
|
|
||||||
wasm-bindgen-futures = "0.4.50"
|
|
||||||
console_error_panic_hook = "0.1.7"
|
|
||||||
log = "0.4.27"
|
|
||||||
wasm-logger = "0.2.0"
|
|
||||||
web-sys = { version = "0.3.77", features = [
|
|
||||||
"OffscreenCanvas",
|
|
||||||
"DedicatedWorkerGlobalScope",
|
|
||||||
"MessageEvent"
|
|
||||||
]}
|
|
||||||
js-sys = "0.3.77"
|
|
||||||
bytemuck = { version = "1.23.1", features = ["derive"] }
|
|
||||||
wgpu = "26.0.1"
|
|
||||||
thiserror = "2.0.15"
|
|
||||||
ultraviolet = "0.10.0"
|
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
opt-level = "z"
|
|
||||||
lto = true
|
|
||||||
-38
@@ -1,38 +0,0 @@
|
|||||||
FROM node:22-alpine
|
|
||||||
|
|
||||||
# Install Rust nightly and required tools
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
build-base \
|
|
||||||
curl \
|
|
||||||
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
|
|
||||||
&& source ~/.cargo/env \
|
|
||||||
&& rustup default nightly-2025-10-20 \
|
|
||||||
&& rustup toolchain install nightly-2025-10-20 \
|
|
||||||
&& rustup target add wasm32-unknown-unknown --toolchain nightly-2025-10-20 \
|
|
||||||
&& rustup component add rust-src --toolchain nightly-2025-10-20 \
|
|
||||||
&& curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
|
||||||
|
|
||||||
# Set environment variables for Rust
|
|
||||||
ENV PATH="/root/.cargo/bin:$PATH"
|
|
||||||
ENV RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals"
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package.json yarn.lock ./
|
|
||||||
|
|
||||||
# Install Node.js dependencies
|
|
||||||
RUN yarn install
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the project
|
|
||||||
RUN yarn run build
|
|
||||||
|
|
||||||
# Expose port for serving
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
# Command to serve the built application
|
|
||||||
CMD ["yarn", "start", "--", "--host"]
|
|
||||||
@@ -1,152 +1,19 @@
|
|||||||
# Yawn
|
# Yawn
|
||||||
|
|
||||||
Yawn is a Rust/WGPU renderer whose application boundary is worker messages plus
|
Yawn Core is two things: a generic structure-of-arrays arena in a `SharedArrayBuffer`, and a render-graph worker that turns externally supplied WGSL into an up-front WebGPU loadout.
|
||||||
shared WebAssembly memory. Backward compatibility is intentionally deferred until
|
|
||||||
1.0.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
FXNode ───────────────┐
|
JSO or FXNode → AST → S-expression → graph worker → WebGPU
|
||||||
├─> canonical DAG AST ─> S-expression ─> Yawn render worker
|
↑
|
||||||
JavaScript objects ──┘ │
|
any thread → direct shared row writes ┘
|
||||||
├─> graph compiler
|
|
||||||
├─> transient allocator
|
|
||||||
└─> prepared GPU loadout
|
|
||||||
|
|
||||||
Any browser thread ── infrequent commands ──────────────────> worker
|
|
||||||
Any browser thread ── atomic SOA writes ────────────────────> shared WASM memory
|
|
||||||
glTF import worker ── parse URL ──> generic render-data packet ─> fixed shared SOA ─┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The canonical AST is the only public render-graph wire format. Nodes are named
|
Messages allocate `{ name, rows, stride, format }` arrays and load graphs. Existing render data is changed by writing `f32`, `u32`, or `i32` rows directly. Allocations are 64-byte aligned, row strides are multiples of 16 bytes, and compatible non-overlapping transient textures share physical allocations.
|
||||||
definitions and `(ref "node" "socket")` forms are edges, so an output can fan out
|
|
||||||
without expanding into a tree. Core parses the S-expression, validates the DAG,
|
|
||||||
culls dead work, calculates resource lifetimes, aliases compatible non-overlapping
|
|
||||||
transients, coalesces render passes, and allocates the resulting textures and GPU
|
|
||||||
pipelines before activating a graph.
|
|
||||||
|
|
||||||
Authored render shaders use Yawn's fixed scene ABI. Render and compute declarations
|
WGSL, pipelines, glTF import, and conventional mesh/camera/material handles live in `addons/`; core contains no shader or scene model.
|
||||||
carry source, entry points, and dispatch/state metadata and are prepared with the
|
|
||||||
graph loadout. Core contains no built-in shader source or pipeline declarations.
|
|
||||||
Its public responsibility stops at shared render data and render-graph compilation,
|
|
||||||
loadouts, lifecycle, and transient resource management; conveniences live outside it.
|
|
||||||
|
|
||||||
## Packages
|
|
||||||
|
|
||||||
- `packages/yawn-core` (`@yawn/core`) — render-data shared arrays and render-graph
|
|
||||||
lifecycle transport; it returns `[slot, generation]` render-data handles.
|
|
||||||
- `addons/render-graph-ast` — canonical immutable DAG AST and S-expression serializer.
|
|
||||||
- `addons/render-graph-js` — plain-object/fluent graph APIs that serialize and load ASTs.
|
|
||||||
- `addons/render-graph-fxnode` — FXNode snapshot exporter and diagnostic mapping.
|
|
||||||
- `addons/default-pipelines` — optional scene/frame shader and compute declarations.
|
|
||||||
- `addons/gltf-import` — glTF worker that writes format-neutral render-data packets directly to a fixed SOA.
|
|
||||||
- `addons/mesh-handles` — conventional mesh, instance, camera, and material objects plus optional BVH picking.
|
|
||||||
|
|
||||||
The editable playground and Render Graph Studio under `examples/` consume the
|
|
||||||
packages through their public APIs; no example source or shader lives in core.
|
|
||||||
Tutorial-style package guides and all focused recipes live under `docs/`, including
|
|
||||||
AST/JSO/FXNode authoring, render and compute programs, graph activation, shared glTF
|
|
||||||
import, mesh instances, custom SOA columns, SAB animation, picking, and worker use.
|
|
||||||
|
|
||||||
Example graph authoring:
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { RenderGraph, ref } from "@yawn/render-graph-js";
|
|
||||||
import { defaultPipelines } from "@yawn/default-pipelines";
|
|
||||||
|
|
||||||
const graph = new RenderGraph("main", 1)
|
|
||||||
.renderPipeline(defaultPipelines.render[1])
|
|
||||||
.renderPipeline(defaultPipelines.render[2])
|
|
||||||
.renderPipeline(defaultPipelines.render[3])
|
|
||||||
.computePipeline({
|
|
||||||
name: "prepare",
|
|
||||||
shader: "@compute @workgroup_size(1) fn main() {}",
|
|
||||||
entry: "main",
|
|
||||||
dispatch: [1, 1, 1],
|
|
||||||
})
|
|
||||||
.node("mesh", "mesh", { version: 2 })
|
|
||||||
.node("draw", "gltf_standard", {
|
|
||||||
version: 2,
|
|
||||||
inputs: { mesh: [ref("mesh", "mesh")] },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add the required attachments and frame output, then let the addon own the wire encoding:
|
|
||||||
await graph.load(core);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Shared render data
|
|
||||||
|
|
||||||
`@yawn/core` exposes 64-byte-aligned shared SOA columns. Every stride is a multiple
|
|
||||||
of 16 bytes and scalar lanes are atomic `u32`, `i32`, or IEEE-754 `f32` bits. The
|
|
||||||
built-in instance transform/type columns are generation-guarded so a stale handle
|
|
||||||
cannot mutate a reused slot. The built-in `camera.state` column is one 64-byte,
|
|
||||||
16-lane `f32` row containing eye, target, up, and projection parameters.
|
|
||||||
|
|
||||||
Allocate application columns infrequently through the worker:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const velocity = await core.allocateArray({
|
|
||||||
name: "instance.velocity",
|
|
||||||
domain: "instance", // also "mesh" or "fixed"
|
|
||||||
scalar: "f32",
|
|
||||||
lanes: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
velocity.write(instanceSlot, [1, 0, 0, 0]);
|
|
||||||
```
|
|
||||||
|
|
||||||
Camera state has no dedicated core API. Read and write it through the same render-data
|
|
||||||
SOA interface as every other hot value; these mutations do not enqueue worker messages:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const camera = core.array("camera.state");
|
|
||||||
const state = camera.read(0);
|
|
||||||
state[0] = nextEye[0];
|
|
||||||
state[1] = nextEye[1];
|
|
||||||
state[2] = nextEye[2];
|
|
||||||
camera.write(0, state);
|
|
||||||
```
|
|
||||||
|
|
||||||
Import a GLB without transferring its bytes through renderer messages:
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { GltfImporter } from "@yawn/gltf-import";
|
|
||||||
import { CameraHandle, MaterialHandles, MeshHandles } from "@yawn/mesh-handles";
|
|
||||||
|
|
||||||
const importer = new GltfImporter(core);
|
|
||||||
const imported = await importer.load(gltfUrl);
|
|
||||||
const meshes = new MeshHandles(core).fromImportedScene(imported);
|
|
||||||
const materials = new MaterialHandles(core).fromImportedScene(imported);
|
|
||||||
const camera = new CameraHandle(core);
|
|
||||||
meshes[0].defaultInstance.setTransform(nextTransform); // direct shared-SOA write
|
|
||||||
materials[0].roughness = 0.35; // direct shared-SOA write
|
|
||||||
camera.position = [4, 3, 6]; // direct shared-SOA write
|
|
||||||
```
|
|
||||||
|
|
||||||
The renderer grows mesh/instance-domain columns with render-data capacity and
|
|
||||||
publishes replacement descriptors through the core's `yawn-soa-layout` event.
|
|
||||||
Typed-array views refresh when shared WASM memory grows. Messages are reserved for
|
|
||||||
allocation and lifecycle operations. Existing instance values and bulk asset uploads
|
|
||||||
use shared memory; a GLB commit message contains only an array ID and byte count.
|
|
||||||
|
|
||||||
`YawnCore` accepts a transport bridge whose worker endpoint can be a `Worker` or a
|
|
||||||
started `MessagePort`, so the same API can run on the browser main thread or another
|
|
||||||
worker. Optional snapshot/BVH picking is owned entirely by the mesh-handles addon.
|
|
||||||
|
|
||||||
Cross-origin isolation is required (`COOP: same-origin`, `COEP: require-corp`). The
|
|
||||||
Vite development and preview servers already set both headers.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run examples
|
npm start
|
||||||
npm run test:js
|
|
||||||
cargo check --workspace
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Production build:
|
This opens the docs. The complete runnable example is at `/playground`.
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run build-release
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,86 +1,50 @@
|
|||||||
export const gltfShader = /* wgsl */ `
|
const triangleShader = /* wgsl */ `
|
||||||
struct UniformData { resolution: vec2<f32>, time: f32, _padding0: f32, camera_position: vec4<f32> }
|
struct Tint { color: 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> tint: Tint;
|
||||||
@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);
|
|
||||||
}`;
|
|
||||||
|
|
||||||
export const groundShader = /* wgsl */ `
|
struct Vertex { @builtin(position) position: vec4<f32> }
|
||||||
@group(0) @binding(0) var<uniform> application: array<vec4<f32>, 3>;
|
|
||||||
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
|
@vertex fn vertex(@builtin(vertex_index) index: u32) -> Vertex {
|
||||||
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> }
|
let positions = array(vec2(-0.75, -0.65), vec2(0.75, -0.65), vec2(0.0, 0.75));
|
||||||
struct Output { @builtin(position) position: vec4<f32>, @location(0) normal: vec3<f32> }
|
var output: Vertex;
|
||||||
@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; }
|
output.position = vec4(positions[index], 0.0, 1.0);
|
||||||
@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); }
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment fn fragment() -> @location(0) vec4<f32> { return tint.color; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const frameShader = /* wgsl */ `
|
const noopComputeShader = /* wgsl */ `
|
||||||
@group(0) @binding(0) var source_texture: texture_2d<f32>;
|
@compute @workgroup_size(1) fn main() {}
|
||||||
@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));}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const noopComputeShader = /* wgsl */ `@compute @workgroup_size(1) fn main() {}`;
|
/** A complete external graph used by the minimal playground; core contains neither program. */
|
||||||
|
export function triangleGraph(colorArray = "triangle.color") {
|
||||||
/** Optional declarations copied into each graph that wants these implementations. */
|
return {
|
||||||
export const defaultPipelines = Object.freeze({
|
id: "triangle",
|
||||||
render: Object.freeze([
|
resources: {
|
||||||
Object.freeze({ name: "ground_plane", shader: groundShader, vertexEntry: "vs_main", fragmentEntry: "fs_main" }),
|
buffers: [{ id: "color", array: colorArray, usage: ["uniform"] }],
|
||||||
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 }),
|
pipelines: {
|
||||||
Object.freeze({ name: "frame_out", shader: frameShader, vertexEntry: "vs_main", fragmentEntry: "fs_frame_out" }),
|
compute: [{ id: "prepare", code: noopComputeShader }],
|
||||||
]),
|
render: [{
|
||||||
compute: Object.freeze([
|
id: "triangle",
|
||||||
Object.freeze({ name: "initialize_scene", shader: noopComputeShader, entry: "main", dispatch: [1, 1, 1] }),
|
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 {
|
/** Fetches and parses glTF in a worker, then lets that worker write the packet into Yawn's SAB arena. */
|
||||||
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. */
|
|
||||||
export class GltfImporter {
|
export class GltfImporter {
|
||||||
#core;
|
#core;
|
||||||
#worker;
|
#worker;
|
||||||
#next = 1;
|
#next = 1;
|
||||||
#pending = new Map();
|
#pending = new Map();
|
||||||
#tail = Promise.resolve();
|
|
||||||
#disposed = false;
|
|
||||||
|
|
||||||
constructor(core, { workerFactory } = {}) {
|
constructor(core, { workerFactory } = {}) {
|
||||||
if (!core?.allocateArray || !core?.commitRenderDataUpload)
|
if (!core?.allocateRows) throw new TypeError("core must be a YawnCore instance");
|
||||||
throw new TypeError("core must implement the Yawn shared render-data protocol");
|
|
||||||
this.#core = core;
|
this.#core = core;
|
||||||
this.#worker = workerFactory
|
this.#worker = workerFactory?.() ?? new Worker(new URL("./worker.js", import.meta.url), {
|
||||||
? workerFactory()
|
type: "module",
|
||||||
: new Worker(new URL("./worker.js", import.meta.url), {
|
name: "yawn-gltf-import",
|
||||||
type: "module",
|
});
|
||||||
name: "yawn-gltf-import",
|
this.#worker.addEventListener("message", ({ data }) => this.#message(data));
|
||||||
});
|
|
||||||
this.#worker.addEventListener("message", event => this.#message(event.data));
|
|
||||||
this.#worker.addEventListener("error", () => this.#fail("GLTF_WORKER_ERROR"));
|
this.#worker.addEventListener("error", () => this.#fail("GLTF_WORKER_ERROR"));
|
||||||
this.#worker.addEventListener("messageerror", () => this.#fail("GLTF_WORKER_ERROR"));
|
|
||||||
this.#worker.start?.();
|
this.#worker.start?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
load(url, options = {}) {
|
load(url) {
|
||||||
if (this.#disposed) return Promise.reject(new GltfImportError("DISPOSED"));
|
|
||||||
const source = url instanceof URL ? url.href : 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"));
|
if (typeof source !== "string" || !source) throw new TypeError("url is required");
|
||||||
const operation = this.#tail.then(() => this.#start(source, options));
|
const request = this.#next++;
|
||||||
this.#tail = operation.catch(() => {});
|
|
||||||
return operation;
|
|
||||||
}
|
|
||||||
|
|
||||||
#start(url, options) {
|
|
||||||
let request = this.#next++ >>> 0;
|
|
||||||
if (!request) request = this.#next++ >>> 0;
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.#pending.set(request, { resolve, reject, options, array: null });
|
this.#pending.set(request, { resolve, reject });
|
||||||
this.#worker.postMessage({ type: "load", request, url });
|
this.#worker.postMessage({ type: "load", request, url: source });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,31 +32,17 @@ export class GltfImporter {
|
|||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
try {
|
try {
|
||||||
if (message.type === "allocate") {
|
if (message.type === "allocate") {
|
||||||
const length = Math.ceil(message.byteLength / 16);
|
pending.array = await this.#core.allocateRows({
|
||||||
pending.array = await this.#core.allocateArray({
|
name: `gltf.${message.request}`,
|
||||||
name: "upload.renderData",
|
rows: Math.ceil(message.byteLength / 16),
|
||||||
domain: "fixed",
|
|
||||||
scalar: "u32",
|
|
||||||
lanes: 4,
|
|
||||||
stride: 16,
|
stride: 16,
|
||||||
length,
|
format: "u32",
|
||||||
});
|
});
|
||||||
this.#worker.postMessage({
|
this.#worker.postMessage({ type: "storage", request: message.request, ...pending.array.share() });
|
||||||
type: "storage",
|
} else {
|
||||||
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.#pending.delete(message.request);
|
this.#pending.delete(message.request);
|
||||||
pending.resolve(result);
|
if (message.type === "ready") pending.resolve({ array: pending.array, byteLength: message.byteLength });
|
||||||
} else if (message.type === "error") {
|
else pending.reject(new Error(message.error ?? "GLTF_IMPORT_FAILED"));
|
||||||
this.#pending.delete(message.request);
|
|
||||||
pending.reject(new GltfImportError(message.code || "GLTF_IMPORT_FAILED"));
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.#pending.delete(message.request);
|
this.#pending.delete(message.request);
|
||||||
@@ -111,16 +51,12 @@ export class GltfImporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#fail(code) {
|
#fail(code) {
|
||||||
if (this.#disposed) return;
|
for (const { reject } of this.#pending.values()) reject(new Error(code));
|
||||||
this.#disposed = true;
|
|
||||||
const error = new GltfImportError(code);
|
|
||||||
for (const pending of this.#pending.values()) pending.reject(error);
|
|
||||||
this.#pending.clear();
|
this.#pending.clear();
|
||||||
this.#worker.terminate?.();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
if (this.#disposed) return;
|
|
||||||
this.#fail("DISPOSED");
|
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";
|
import { gltfToRenderDataPacket } from "./gltf.js";
|
||||||
|
|
||||||
const downloads = new Map();
|
const downloads = new Map();
|
||||||
@@ -19,12 +18,16 @@ addEventListener("message", async ({ data: message }) => {
|
|||||||
if (message?.type === "storage") {
|
if (message?.type === "storage") {
|
||||||
const packet = downloads.get(request);
|
const packet = downloads.get(request);
|
||||||
if (!packet) throw new Error("GLTF_REQUEST_UNKNOWN");
|
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);
|
downloads.delete(request);
|
||||||
postMessage({ type: "ready", request, byteLength: packet.byteLength });
|
postMessage({ type: "ready", request, byteLength: packet.byteLength });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
downloads.delete(request);
|
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",
|
"version": "0.1.0",
|
||||||
"description": "Conventional mesh, instance, camera, and material handles over Yawn render data",
|
"description": "Conventional mesh, instance, camera, and material handles over Yawn render data",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js"
|
||||||
"dependencies": {
|
|
||||||
"@yawn/core": "0.1.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||||
import { SnapshotReader } from "./snapshot.js";
|
|
||||||
|
|
||||||
const TOKEN = Symbol("yawn mesh handle addon");
|
class Handle {
|
||||||
const SNAPSHOT_EVENT = "yawn-render-data-snapshot";
|
constructor(array, row = 0) {
|
||||||
const PUBLISHED_EVENT = "yawn-render-data-snapshot-published";
|
this.array = array;
|
||||||
const createPickingWorker = () => new Worker(
|
this.row = row;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
get state() { return this.array.read(this.row); }
|
||||||
fromImportedScene(result) {
|
set state(value) { this.array.write(this.row, value); }
|
||||||
if (!result || !Array.isArray(result.meshes)) throw new TypeError("invalid imported scene");
|
patch(offset, values) {
|
||||||
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}'`);
|
|
||||||
const state = this.state;
|
const state = this.state;
|
||||||
if (properties.position !== undefined) state.splice(0, 3, ...vector(properties.position, 3, "position"));
|
state.splice(offset, values.length, ...values);
|
||||||
if (properties.target !== undefined) state.splice(4, 3, ...vector(properties.target, 3, "target"));
|
this.state = state;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const FLOAT_WORD = new ArrayBuffer(4);
|
/** Conventional camera values over one caller-owned shared row. */
|
||||||
const FLOAT_VIEW = new Float32Array(FLOAT_WORD);
|
export class CameraHandle extends Handle {
|
||||||
const WORD_VIEW = new Uint32Array(FLOAT_WORD);
|
static async create(core, name = "camera") {
|
||||||
function wordToFloat(word) { WORD_VIEW[0] = word; return FLOAT_VIEW[0]; }
|
const array = await core.allocateRows({ name, rows: 1, stride: 64, format: "f32" });
|
||||||
function floatToWord(value) { FLOAT_VIEW[0] = value; return WORD_VIEW[0]; }
|
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];
|
||||||
/** Creates scene-scoped material objects over packed material SOA rows. */
|
return camera;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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 {
|
/** Conventional material properties over one eight-float shared row. */
|
||||||
#array; #key;
|
export class MaterialHandle extends Handle {
|
||||||
|
static async create(core, name = "material") {
|
||||||
constructor(token, array, key) {
|
const material = new MaterialHandle(await core.allocateRows({ name, rows: 1, stride: 32, format: "f32" }));
|
||||||
if (token !== TOKEN) throw new TypeError("MaterialHandle cannot be constructed directly");
|
material.state = [1, 1, 1, 1, 0, 1, 0, 0];
|
||||||
this.#array = array;
|
return material;
|
||||||
this.#key = key;
|
|
||||||
}
|
}
|
||||||
|
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; }
|
/** Conventional mesh transform over one SIMD-aligned shared row. */
|
||||||
get baseColor() { return this.#floats(0, 4); }
|
export class MeshHandle extends Handle {
|
||||||
set baseColor(value) { this.update({ baseColor: value }); }
|
static async create(core, name = "mesh") {
|
||||||
get emissive() { return this.#floats(4, 3); }
|
const mesh = new MeshHandle(await core.allocateRows({ name, rows: 1, stride: 64, format: "f32" }));
|
||||||
set emissive(value) { this.update({ emissive: value }); }
|
mesh.transform = identity;
|
||||||
get metallic() { return this.#float(8); }
|
return mesh;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
get transform() { return this.state; }
|
||||||
#float(lane) { return wordToFloat(this.#array.read(this.#key)[lane]); }
|
set transform(value) { this.state = value; }
|
||||||
#floats(start, length) { return this.#array.read(this.#key).slice(start, start + length).map(wordToFloat); }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
export const GRAPH_AST_VERSION = 1;
|
||||||
const AST_KIND = "yawn-render-graph";
|
|
||||||
const AST_VERSION = 1;
|
|
||||||
const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_.-]*$/;
|
|
||||||
|
|
||||||
export class GraphAstError extends TypeError {
|
const data = value => value === null || typeof value === "string" || typeof value === "boolean" ||
|
||||||
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" ||
|
|
||||||
(typeof value === "number" && Number.isFinite(value)) ||
|
(typeof value === "number" && Number.isFinite(value)) ||
|
||||||
(Array.isArray(value) && value.every(finiteData)) ||
|
(Array.isArray(value) && value.every(data)) ||
|
||||||
(object(value) && Object.values(value).every(finiteData));
|
(value?.constructor === Object && Object.values(value).every(data));
|
||||||
const clone = (value) => structuredClone(value);
|
|
||||||
const freeze = (value) => {
|
function freeze(value) {
|
||||||
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
if (value && typeof value === "object") {
|
||||||
Object.freeze(value);
|
|
||||||
Object.values(value).forEach(freeze);
|
Object.values(value).forEach(freeze);
|
||||||
|
Object.freeze(value);
|
||||||
}
|
}
|
||||||
return 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) {
|
/** Creates the data-only AST consumed by every graph frontend. DAG edges are pass `after` IDs. */
|
||||||
if (!object(raw) || !identifier(raw.id) || !object(raw.executor))
|
export function createGraphAst(graph) {
|
||||||
fail("AST_NODE", "invalid node");
|
if (!data(graph) || graph?.constructor !== Object || typeof graph.id !== "string" ||
|
||||||
if (raw.state !== "enabled" && raw.state !== "muted")
|
!Array.isArray(graph.passes)) throw new TypeError("GRAPH_AST");
|
||||||
fail("AST_NODE", "node state must be enabled or muted");
|
return freeze(structuredClone(graph));
|
||||||
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 canonical in-memory graph AST shared by all authoring frontends. */
|
function encode(value) {
|
||||||
export function createGraphAst({ kind, version, id, revision, pipelines = {}, nodes }) {
|
if (value === null || typeof value === "boolean" || typeof value === "number") return String(value);
|
||||||
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);
|
|
||||||
}
|
|
||||||
if (typeof value === "string") return JSON.stringify(value);
|
if (typeof value === "string") return JSON.stringify(value);
|
||||||
if (Array.isArray(value)) return `(array${value.map((item) => ` ${data(item)}`).join("")})`;
|
if (Array.isArray(value)) return `(array${value.map(item => ` ${encode(item)}`).join("")})`;
|
||||||
if (object(value))
|
return `(object${Object.keys(value).sort().map(key =>
|
||||||
return `(object${Object.keys(value)
|
` (field ${JSON.stringify(key)} ${encode(value[key])})`).join("")})`;
|
||||||
.sort()
|
|
||||||
.map((key) => ` (field ${JSON.stringify(key)} ${data(value[key])})`)
|
|
||||||
.join("")})`;
|
|
||||||
fail("AST_DATA", "unsupported AST data value");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serializes a graph AST to the only graph wire format accepted by Yawn core. */
|
/** Serializes the AST as an S-expression; named pass references preserve DAG fan-out. */
|
||||||
export function serializeGraphAst(raw) {
|
export function serializeGraphAst(graph) {
|
||||||
const graph = createGraphAst(raw);
|
return `(yawn-graph ${GRAPH_AST_VERSION} ${encode(createGraphAst(graph))})`;
|
||||||
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`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GRAPH_AST_KIND = AST_KIND;
|
|
||||||
export const GRAPH_AST_VERSION = AST_VERSION;
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "FXNode frontend for Yawn render graphs",
|
"description": "FXNode frontend for Yawn render graphs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": "./src/index.js",
|
||||||
".": "./src/index.js",
|
|
||||||
"./catalog": "./src/catalog.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@yawn/render-graph-ast": "0.1.0"
|
"@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
|
import { createGraphAst } from "@yawn/render-graph-ast";
|
||||||
// FXNode-aware code that feeds the canonical AST package.
|
|
||||||
export {
|
/** Exports FXNode nodes with a `pass` payload and links as the canonical pass DAG. */
|
||||||
adaptFxNodeSnapshot,
|
export function adaptFxNodeSnapshot(snapshot, { pipelines = {}, resources = {} } = {}) {
|
||||||
mapAuthoringDiagnostic,
|
if (!Array.isArray(snapshot?.nodes) || !Array.isArray(snapshot?.links)) throw new TypeError("FXNODE_GRAPH");
|
||||||
} from "./adapter.js";
|
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 {
|
import { createGraphAst, serializeGraphAst } from "@yawn/render-graph-ast";
|
||||||
createGraphAst,
|
|
||||||
reference,
|
|
||||||
serializeGraphAst,
|
|
||||||
} from "@yawn/render-graph-ast";
|
|
||||||
|
|
||||||
/** Compiles a plain JavaScript object description into the canonical graph AST. */
|
/** Converts a plain JavaScript object into the canonical render-graph AST. */
|
||||||
export const graphFromObject = (description) => createGraphAst(description);
|
export const graphFromObject = graph => createGraphAst(graph);
|
||||||
|
|
||||||
/** Small mutable authoring facade; `ast()` returns an immutable canonical AST. */
|
/** Serializes a JSO graph and asks Yawn Core to prepare and activate its loadout. */
|
||||||
export class RenderGraph {
|
export function loadGraph(core, graph) {
|
||||||
#id;
|
if (!core?.loadGraph) throw new TypeError("core must be a YawnCore instance");
|
||||||
#revision;
|
return core.loadGraph(serializeGraphAst(graphFromObject(graph)));
|
||||||
#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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Canonicalizes a JSO/AST and sends its S-expression wire form to Yawn core. */
|
export { serializeGraphAst };
|
||||||
export function loadGraph(core, description) {
|
|
||||||
return core.compileGraph(serializeGraphAst(graphFromObject(description)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export { reference as ref, serializeGraphAst };
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from "vue";
|
||||||
|
import { YawnCore } from "@yawn/core";
|
||||||
|
import { loadGraph } from "@yawn/render-graph-js";
|
||||||
|
import { triangleGraph } from "@yawn/default-pipelines";
|
||||||
|
|
||||||
|
const canvas = ref();
|
||||||
|
const status = ref("Starting…");
|
||||||
|
const failed = ref(false);
|
||||||
|
let core;
|
||||||
|
let color;
|
||||||
|
|
||||||
|
function move(event) {
|
||||||
|
if (!color) return;
|
||||||
|
const bounds = canvas.value.getBoundingClientRect();
|
||||||
|
const row = color.row(0);
|
||||||
|
row[0] = (event.clientX - bounds.left) / bounds.width;
|
||||||
|
row[1] = 1 - (event.clientY - bounds.top) / bounds.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
if (!crossOriginIsolated) throw new Error("Cross-origin isolation is disabled");
|
||||||
|
canvas.value.width = 960;
|
||||||
|
canvas.value.height = 540;
|
||||||
|
core = new YawnCore(canvas.value);
|
||||||
|
await core.ready;
|
||||||
|
color = await core.allocateRows({
|
||||||
|
name: "triangle.color",
|
||||||
|
rows: 1,
|
||||||
|
stride: 16,
|
||||||
|
format: "f32",
|
||||||
|
});
|
||||||
|
color.write(0, [0.2, 0.65, 1, 1]);
|
||||||
|
await loadGraph(core, triangleGraph());
|
||||||
|
window.__yawnPlayground = { core, color };
|
||||||
|
status.value = "Running · move the pointer to write the shared color row";
|
||||||
|
} catch (error) {
|
||||||
|
failed.value = true;
|
||||||
|
status.value = error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
delete window.__yawnPlayground;
|
||||||
|
core?.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="playground">
|
||||||
|
<canvas ref="canvas" aria-label="Yawn WebGPU output" @pointermove="move" />
|
||||||
|
<p :class="{ failed }" data-playground-status>{{ status }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.playground { margin: 24px 0; }
|
||||||
|
canvas { width: 100%; aspect-ratio: 16 / 9; display: block; background: #111827; border-radius: 12px; }
|
||||||
|
p { color: var(--vp-c-text-2); }
|
||||||
|
.failed { color: var(--vp-c-danger-1); }
|
||||||
|
</style>
|
||||||
+26
-57
@@ -1,67 +1,36 @@
|
|||||||
import { defineConfig } from "vitepress";
|
import { defineConfig } from "vitepress";
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"Cross-Origin-Opener-Policy": "same-origin",
|
||||||
|
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||||
|
};
|
||||||
|
|
||||||
|
const isolation = {
|
||||||
|
name: "cross-origin-isolation",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((_, response, next) => {
|
||||||
|
for (const [name, value] of Object.entries(headers)) {
|
||||||
|
response.setHeader(name, value);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
title: "Yawn",
|
title: "Yawn",
|
||||||
description: "Worker-native WebGPU rendering with shared render data.",
|
description: "Shared render data and a render graph.",
|
||||||
base: "/docs/",
|
|
||||||
outDir: "../dist/docs",
|
|
||||||
cleanUrls: true,
|
cleanUrls: true,
|
||||||
head: [
|
|
||||||
["meta", { name: "theme-color", content: "#0d1117" }],
|
|
||||||
["link", { rel: "icon", href: "data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><rect width=%2264%22 height=%2264%22 rx=%2214%22 fill=%22%230d1117%22/><path d=%22M13 14h10l9 17 9-17h10L37 40v11H27V40z%22 fill=%22%23ed7946%22/></svg>" }],
|
|
||||||
],
|
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
logo: {
|
|
||||||
light: "data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 42 42%22><rect width=%2242%22 height=%2242%22 rx=%229%22 fill=%22%2311161e%22/><path d=%22M8 9h7l6 11 6-11h7l-9 17v7h-8v-7z%22 fill=%22%23ed7946%22/></svg>",
|
|
||||||
dark: "data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 42 42%22><rect width=%2242%22 height=%2242%22 rx=%229%22 fill=%22%2311161e%22/><path d=%22M8 9h7l6 11 6-11h7l-9 17v7h-8v-7z%22 fill=%22%23ed7946%22/></svg>",
|
|
||||||
},
|
|
||||||
nav: [
|
nav: [
|
||||||
{ text: "Learn", link: "/guide/first-scene" },
|
{ text: "Architecture", link: "/" },
|
||||||
{ text: "Packages", link: "/packages/" },
|
{ text: "Playground", link: "/playground" },
|
||||||
{ text: "Recipes", link: "/recipes/" },
|
|
||||||
{ text: "Playground", link: "/../playground/" },
|
|
||||||
],
|
],
|
||||||
sidebar: [
|
},
|
||||||
{
|
vite: {
|
||||||
text: "Get started",
|
plugins: [isolation],
|
||||||
items: [
|
worker: { format: "es" },
|
||||||
{ text: "Your first scene", link: "/guide/first-scene" },
|
server: { allowedHosts: true, headers },
|
||||||
{ text: "How Yawn fits together", link: "/guide/architecture" },
|
preview: { allowedHosts: true, headers },
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Package tutorials",
|
|
||||||
items: [
|
|
||||||
{ text: "Package map", link: "/packages/" },
|
|
||||||
{ text: "Core and render data", link: "/packages/core" },
|
|
||||||
{ text: "Render graph frontends", link: "/packages/render-graph" },
|
|
||||||
{ text: "glTF import worker", link: "/packages/gltf-import" },
|
|
||||||
{ text: "Conventional handles", link: "/packages/mesh-handles" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Recipes",
|
|
||||||
items: [
|
|
||||||
{ text: "All recipes", link: "/recipes/" },
|
|
||||||
{ text: "Graph authoring", link: "/recipes/graph-authoring" },
|
|
||||||
{ text: "Pipelines and loadouts", link: "/recipes/pipelines" },
|
|
||||||
{ text: "Assets and render data", link: "/recipes/render-data" },
|
|
||||||
{ text: "Runtime interaction", link: "/recipes/runtime" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
socialLinks: [
|
|
||||||
{ icon: "github", link: "https://github.com/heaust-ops/yawn" },
|
|
||||||
],
|
|
||||||
search: { provider: "local" },
|
|
||||||
outline: { level: [2, 3] },
|
|
||||||
editLink: {
|
|
||||||
pattern: "https://github.com/heaust-ops/yawn/edit/feat/core/docs/:path",
|
|
||||||
text: "Edit this page on GitHub",
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
message: "Core owns render data and render graphs. Addons own conveniences.",
|
|
||||||
copyright: "Yawn is pre-1.0 software.",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from "vue";
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
id: { type: String, required: true },
|
|
||||||
title: { type: String, required: true },
|
|
||||||
description: { type: String, default: "Run this recipe against the real Yawn worker." },
|
|
||||||
});
|
|
||||||
|
|
||||||
const query = computed(() => encodeURIComponent(props.id));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="yawn-playground">
|
|
||||||
<div class="yawn-playground__header">
|
|
||||||
<div>
|
|
||||||
<span>LIVE PLAYGROUND</span>
|
|
||||||
<strong>{{ title }}</strong>
|
|
||||||
<p>{{ description }}</p>
|
|
||||||
</div>
|
|
||||||
<a :href="`/playground/?recipe=${query}`">Edit and run ↗</a>
|
|
||||||
</div>
|
|
||||||
<ClientOnly>
|
|
||||||
<iframe
|
|
||||||
:src="`/playground/runner.html?recipe=${query}&embed=1`"
|
|
||||||
:title="`${title} live preview`"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</ClientOnly>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
:root {
|
|
||||||
--vp-c-brand-1: #d86535;
|
|
||||||
--vp-c-brand-2: #ee7946;
|
|
||||||
--vp-c-brand-3: #f1956d;
|
|
||||||
--vp-c-brand-soft: rgba(224, 104, 54, 0.14);
|
|
||||||
--vp-home-hero-name-color: transparent;
|
|
||||||
--vp-home-hero-name-background: linear-gradient(110deg, #ef7b47, #f5bc75);
|
|
||||||
--vp-home-hero-image-background-image: radial-gradient(circle, #da633950 0%, transparent 68%);
|
|
||||||
--vp-home-hero-image-filter: blur(44px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--vp-c-bg: #0a0d12;
|
|
||||||
--vp-c-bg-alt: #0f141c;
|
|
||||||
--vp-c-bg-soft: #121821;
|
|
||||||
--vp-c-bg-elv: #151c26;
|
|
||||||
--vp-c-divider: #27303c;
|
|
||||||
--vp-code-block-bg: #0b1017;
|
|
||||||
}
|
|
||||||
|
|
||||||
.VPNavBarTitle .title { letter-spacing: 0.08em; }
|
|
||||||
.VPHomeHero .name { letter-spacing: -0.045em; }
|
|
||||||
.VPHomeHero .text { max-width: 760px; letter-spacing: -0.04em; }
|
|
||||||
.VPHomeHero .tagline { max-width: 610px; }
|
|
||||||
.VPFeature { border-color: var(--vp-c-divider); }
|
|
||||||
|
|
||||||
.yawn-playground {
|
|
||||||
margin: 28px 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--vp-c-divider);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #080b10;
|
|
||||||
box-shadow: 0 18px 55px #0003;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yawn-playground__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 24px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
border-bottom: 1px solid #26303c;
|
|
||||||
background: #111720;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yawn-playground__header > div { display: grid; gap: 3px; }
|
|
||||||
.yawn-playground__header span {
|
|
||||||
color: #ef7b47;
|
|
||||||
font: 700 10px ui-monospace, monospace;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
}
|
|
||||||
.yawn-playground__header strong { color: #f1f4f8; font-size: 15px; }
|
|
||||||
.yawn-playground__header p { margin: 0; color: #8f9bac; font-size: 12px; }
|
|
||||||
.yawn-playground__header a {
|
|
||||||
flex: none;
|
|
||||||
padding: 8px 11px;
|
|
||||||
border: 1px solid #e47748;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #fff;
|
|
||||||
background: #c65d2f;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.yawn-playground iframe { display: block; width: 100%; height: 360px; border: 0; }
|
|
||||||
|
|
||||||
.package-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin: 24px 0;
|
|
||||||
}
|
|
||||||
.package-grid > a {
|
|
||||||
display: block;
|
|
||||||
padding: 18px;
|
|
||||||
border: 1px solid var(--vp-c-divider);
|
|
||||||
border-radius: 10px;
|
|
||||||
color: var(--vp-c-text-1);
|
|
||||||
background: var(--vp-c-bg-soft);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.package-grid > a:hover { border-color: var(--vp-c-brand-1); }
|
|
||||||
.package-grid strong { display: block; margin-bottom: 4px; }
|
|
||||||
.package-grid span { color: var(--vp-c-text-2); font-size: 13px; }
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.package-grid { grid-template-columns: 1fr; }
|
|
||||||
.yawn-playground__header { align-items: flex-start; flex-direction: column; }
|
|
||||||
.yawn-playground iframe { height: 280px; }
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import DefaultTheme from "vitepress/theme";
|
|
||||||
import Playground from "./Playground.vue";
|
|
||||||
import "./custom.css";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
extends: DefaultTheme,
|
|
||||||
enhanceApp({ app }) {
|
|
||||||
app.component("Playground", Playground);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# How Yawn fits together
|
|
||||||
|
|
||||||
Yawn has two public boundaries: **worker communication** for small, infrequent operations and **shared render data** for values that change often. Everything else is an authoring or convenience layer outside core.
|
|
||||||
|
|
||||||
```text
|
|
||||||
JSO / fluent builder ─┐
|
|
||||||
├──▶ canonical DAG AST ─▶ S-expression ─▶ render worker
|
|
||||||
FXNode snapshot ─────┘ │
|
|
||||||
├─ graph compiler
|
|
||||||
glTF import worker ───── shared upload array ────────────────────┤
|
|
||||||
├─ transient allocator
|
|
||||||
any browser thread ─── lifecycle messages ──────────────────────┤
|
|
||||||
any browser thread ─── atomic SOA writes ────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## What core owns
|
|
||||||
|
|
||||||
`@yawn/core` owns only the protocol client for render data and render graphs. The worker behind it owns graph validation, loadout preparation, transient lifetime analysis, GPU allocation, and rendering.
|
|
||||||
|
|
||||||
Core does **not** own a camera module, scene object model, glTF parser, shader library, editor, or picking system. Camera and material values are ordinary render-data columns. Higher-level objects are optional addon views over those columns.
|
|
||||||
|
|
||||||
## The graph is the program
|
|
||||||
|
|
||||||
Every frontend must produce `@yawn/render-graph-ast` data. A node is named, while an input contains one or more `{ node, socket }` references. Reusing the same reference creates fan-out, so the format describes a DAG instead of duplicating a tree.
|
|
||||||
|
|
||||||
Render and compute pipeline declarations are part of that AST. Their WGSL and state are compiled into a prepared loadout, not linked into core.
|
|
||||||
|
|
||||||
## The SOA is the mutable scene
|
|
||||||
|
|
||||||
Shared arrays are 64-byte aligned and each row stride is a multiple of 16 bytes. The standard columns cover mesh, instance, camera, and material data. Applications can request additional mesh-, instance-, or fixed-domain arrays; domain arrays grow with the corresponding render-data capacity.
|
|
||||||
|
|
||||||
Lifecycle operations such as allocating a column, importing an asset, compiling a graph, or creating an instance cross the command boundary. A frame-rate transform, camera, classification, or material update writes the existing SAB row directly.
|
|
||||||
|
|
||||||
## Thread placement is a choice
|
|
||||||
|
|
||||||
The JS client only requires a Worker-like endpoint. A main thread can own it, or another worker can connect through a `MessagePort`. Shared descriptors can be passed to additional workers, which can then read or write the same SOA data without proxying every update through the main thread.
|
|
||||||
|
|
||||||
::: warning Cross-origin isolation is required
|
|
||||||
Serve the application with `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`. `npm run examples` supplies both headers.
|
|
||||||
:::
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Your first scene
|
|
||||||
|
|
||||||
This tutorial composes Yawn the same way an application does: create the worker transport, wait for shared render data, compile a graph, import an asset, and activate the prepared loadout.
|
|
||||||
|
|
||||||
## 1. Start core
|
|
||||||
|
|
||||||
Create one renderer worker and transfer an `OffscreenCanvas` to it. `YawnCore` is deliberately transport-oriented; your bootstrap owns canvas sizing and worker construction.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { YawnCore } from "@yawn/core";
|
|
||||||
|
|
||||||
const canvas = document.querySelector("canvas");
|
|
||||||
canvas.width = Math.round(canvas.clientWidth * devicePixelRatio);
|
|
||||||
canvas.height = Math.round(canvas.clientHeight * devicePixelRatio);
|
|
||||||
const offscreen = canvas.transferControlToOffscreen();
|
|
||||||
const worker = new Worker(new URL("./render-worker.js", import.meta.url), {
|
|
||||||
type: "module",
|
|
||||||
});
|
|
||||||
const core = new YawnCore({ worker });
|
|
||||||
worker.postMessage({ type: "init", canvas: offscreen }, [offscreen]);
|
|
||||||
await core.ready;
|
|
||||||
```
|
|
||||||
|
|
||||||
`ready` resolves after core receives the standard SOA descriptors. From that point, `core.array("camera.state")` and the other built-in columns are safe to access.
|
|
||||||
|
|
||||||
## 2. Compile a graph
|
|
||||||
|
|
||||||
The optional default-pipelines addon supplies scene WGSL. A JSO graph places those declarations beside graph nodes, then the graph addon serializes the canonical AST for core.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { loadGraph } from "@yawn/render-graph-js";
|
|
||||||
import { defaultPipelines } from "@yawn/default-pipelines";
|
|
||||||
|
|
||||||
const graph = {
|
|
||||||
id: "main",
|
|
||||||
revision: 1,
|
|
||||||
pipelines: defaultPipelines,
|
|
||||||
nodes: completeSceneNodes,
|
|
||||||
};
|
|
||||||
|
|
||||||
const compiled = await loadGraph(core, graph);
|
|
||||||
```
|
|
||||||
|
|
||||||
Compilation validates the DAG, removes dead work, computes transient resource lifetimes, aliases compatible resources, and allocates the resulting loadout before returning its ID.
|
|
||||||
|
|
||||||
## 3. Import render data
|
|
||||||
|
|
||||||
The glTF addon fetches and parses in its own worker. It asks core for a fixed shared upload array, writes the packet into that SAB, then sends only the array ID and byte count for the commit.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { GltfImporter } from "@yawn/gltf-import";
|
|
||||||
import { MeshHandles } from "@yawn/mesh-handles";
|
|
||||||
|
|
||||||
const importer = new GltfImporter(core);
|
|
||||||
const result = await importer.load("/assets/scene.glb");
|
|
||||||
const handles = new MeshHandles(core);
|
|
||||||
const meshes = handles.fromImportedScene(result);
|
|
||||||
importer.dispose();
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Activate the loadout
|
|
||||||
|
|
||||||
Switching is transactional from the application's perspective: the previously active loadout keeps rendering until the prepared graph becomes active.
|
|
||||||
|
|
||||||
```js
|
|
||||||
await core.switchCompiledGraph(compiled.compiledId);
|
|
||||||
|
|
||||||
meshes[0].defaultInstance.setTransform(nextTransform); // direct SAB write
|
|
||||||
```
|
|
||||||
|
|
||||||
Use messages for setup and teardown. Use shared writes for values that are already present and can change every frame.
|
|
||||||
|
|
||||||
<Playground
|
|
||||||
id="first-scene"
|
|
||||||
title="Complete first scene"
|
|
||||||
description="Open the editor to change the procedural loadout or inspect live telemetry."
|
|
||||||
/>
|
|
||||||
|
|
||||||
## Next steps
|
|
||||||
|
|
||||||
- Learn why these boundaries exist in [How Yawn fits together](./architecture).
|
|
||||||
- Author graphs with [plain objects, a fluent builder, or FXNode](../packages/render-graph).
|
|
||||||
- Add custom shared columns in [Core and render data](../packages/core).
|
|
||||||
- Use familiar objects in [Conventional handles](../packages/mesh-handles).
|
|
||||||
+31
-24
@@ -1,35 +1,42 @@
|
|||||||
---
|
---
|
||||||
layout: home
|
layout: home
|
||||||
|
|
||||||
hero:
|
hero:
|
||||||
name: Yawn
|
name: Yawn
|
||||||
text: Build the graph. Share the data.
|
text: Shared render data and a render graph.
|
||||||
tagline: A worker-native WebGPU renderer where infrequent lifecycle commands use messages and hot render data lives in SIMD-aligned shared memory.
|
tagline: Two core files, one fixed arena, no built-in scene model or shader.
|
||||||
actions:
|
actions:
|
||||||
- theme: brand
|
- theme: brand
|
||||||
text: Build your first scene
|
|
||||||
link: /guide/first-scene
|
|
||||||
- theme: alt
|
|
||||||
text: Open the playground
|
text: Open the playground
|
||||||
link: /../playground/
|
link: /playground
|
||||||
|
|
||||||
features:
|
features:
|
||||||
- title: One graph boundary
|
- title: Shared rows
|
||||||
details: JSO, a fluent builder, and FXNode all export the same immutable DAG AST and S-expression wire format.
|
details: Allocate an SOA row array once by message, then mutate its SAB views directly from any thread.
|
||||||
- title: Shared render data
|
- title: External graphs
|
||||||
details: Meshes, instances, camera state, materials, and user columns use aligned SOA rows backed by shared WASM memory.
|
details: JSO and FXNode addons serialize DAGs to the S-expression AST consumed by the worker.
|
||||||
- title: External programs
|
|
||||||
details: WGSL, render pipelines, and compute passes travel with a graph loadout; core ships no scene shader.
|
|
||||||
- title: Worker-native
|
|
||||||
details: The same core client runs on the browser main thread or another worker through a Worker-like endpoint.
|
|
||||||
- title: Up-front loadouts
|
- title: Up-front loadouts
|
||||||
details: Graph compilation culls dead work, aliases compatible transients, coalesces passes, and prepares resources before activation.
|
details: Pipelines, GPU resources, pass order, and compatible transient aliases are prepared before activation.
|
||||||
- title: Optional conveniences
|
|
||||||
details: glTF import, mesh handles, material properties, camera controls, and picking stay in focused addons.
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Playground
|
## The entire boundary
|
||||||
id="first-scene"
|
|
||||||
title="Your first Yawn scene"
|
```js
|
||||||
description="The preview imports procedural glTF through a worker, activates a graph, and renders shared instance data."
|
const color = await core.allocateRows({
|
||||||
/>
|
name: "triangle.color",
|
||||||
|
rows: 1,
|
||||||
|
stride: 16,
|
||||||
|
format: "f32",
|
||||||
|
});
|
||||||
|
|
||||||
|
color.write(0, [0.2, 0.65, 1, 1]);
|
||||||
|
color.row(0)[0] = 0.8; // direct SharedArrayBuffer write
|
||||||
|
await loadGraph(core, graph); // infrequent message
|
||||||
|
```
|
||||||
|
|
||||||
|
`@yawn/core` contains only the public shared-row client and its worker. The worker owns the fixed 64-byte-aligned arena, S-expression graph compiler, WebGPU loadout, and transient texture aliasing. Every scene convention and every byte of WGSL comes from an addon or application.
|
||||||
|
|
||||||
|
```text
|
||||||
|
JSO / FXNode ──▶ AST ──▶ S-expression ──▶ core worker ──▶ WebGPU
|
||||||
|
any JS thread ───────────── direct SAB row writes ────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The addon packages provide graph serialization, optional WGSL, glTF import directly into shared rows, and conventional camera/material/mesh handles. None of them add semantics to core.
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
# Core and render data
|
|
||||||
|
|
||||||
`@yawn/core` is a protocol client. It manages the command ring, payload handshakes, graph lifecycle, and typed views over shared render-data arrays.
|
|
||||||
|
|
||||||
## Fast-path shared writes
|
|
||||||
|
|
||||||
Standard instance APIs validate `[slot, generation]`, then write the corresponding guarded SOA row. They do not enqueue a renderer command.
|
|
||||||
|
|
||||||
```js
|
|
||||||
core.setInstanceTransform(instanceHandle, matrix);
|
|
||||||
core.setInstanceType(instanceHandle, sixteenU32Words);
|
|
||||||
```
|
|
||||||
|
|
||||||
The convenience `Instance` methods in `@yawn/mesh-handles` call exactly these APIs.
|
|
||||||
|
|
||||||
<Playground
|
|
||||||
id="shared-animation"
|
|
||||||
title="Direct shared-memory animation"
|
|
||||||
description="A requestAnimationFrame loop updates one instance transform without per-frame messages."
|
|
||||||
/>
|
|
||||||
|
|
||||||
## Request an SOA column
|
|
||||||
|
|
||||||
Array creation is intentionally an infrequent worker command. Choose a domain so core can keep the array's logical length synchronized with fixed, mesh, or instance capacity.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const velocity = await core.allocateArray({
|
|
||||||
name: "instance.velocity",
|
|
||||||
domain: "instance",
|
|
||||||
scalar: "f32",
|
|
||||||
lanes: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
velocity.write(instanceHandle[0], [1, 0, 0, 0]);
|
|
||||||
```
|
|
||||||
|
|
||||||
Every stride is a multiple of 16 bytes, keeping rows suitable for vectorized consumers. `SharedSoaArray` uses atomic lane access and refreshes its typed views when shared WASM memory grows.
|
|
||||||
|
|
||||||
<Playground
|
|
||||||
id="custom-soa"
|
|
||||||
title="Application-owned velocity rows"
|
|
||||||
description="Allocate an instance-domain column and populate one SIMD-width row per live instance."
|
|
||||||
/>
|
|
||||||
|
|
||||||
## Share a column with another worker
|
|
||||||
|
|
||||||
Use `share()` only during setup. It returns the shared backing buffer and wire descriptor needed to construct a compatible view in another package or worker.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const { buffer, descriptor } = velocity.share();
|
|
||||||
simulationWorker.postMessage({ type: "velocity-layout", buffer, descriptor });
|
|
||||||
```
|
|
||||||
|
|
||||||
The `SharedArrayBuffer` is shared, not transferred. Once installed, the simulation worker should mutate rows directly and reserve messages for layout or lifecycle changes.
|
|
||||||
|
|
||||||
## Graph lifecycle
|
|
||||||
|
|
||||||
Core accepts one graph format: the serialized S-expression produced by `@yawn/render-graph-ast`.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const compiled = await core.compileGraph(serializedAst);
|
|
||||||
await core.switchCompiledGraph(compiled.compiledId);
|
|
||||||
await core.dropCompiledGraph(oldCompiledId);
|
|
||||||
```
|
|
||||||
|
|
||||||
Graph operations are serialized by the client so compile, switch, and drop cannot race each other on one core instance.
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# glTF import worker
|
|
||||||
|
|
||||||
`@yawn/gltf-import` keeps parsing and bulk upload off the renderer command channel. It fetches a `.gltf` or `.glb` URL in a dedicated worker and writes a format-neutral packet directly into shared memory.
|
|
||||||
|
|
||||||
## Load a scene
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { GltfImporter } from "@yawn/gltf-import";
|
|
||||||
|
|
||||||
const importer = new GltfImporter(core);
|
|
||||||
try {
|
|
||||||
const result = await importer.load("/models/level.glb");
|
|
||||||
console.log(result.meshes, result.materials, result.bounds);
|
|
||||||
} finally {
|
|
||||||
importer.dispose();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The import handshake is:
|
|
||||||
|
|
||||||
1. The import worker fetches and measures the asset packet.
|
|
||||||
2. Core allocates a fixed `upload.renderData` shared array.
|
|
||||||
3. The import worker writes packet bytes into that SAB.
|
|
||||||
4. Core receives only the array ID and byte count, then installs render data.
|
|
||||||
|
|
||||||
No GLB payload is copied through the renderer's message queue.
|
|
||||||
|
|
||||||
<Playground
|
|
||||||
id="gltf-worker"
|
|
||||||
title="Worker-side glTF import"
|
|
||||||
description="A generated GLB is fetched through an object URL and committed from shared upload memory."
|
|
||||||
/>
|
|
||||||
|
|
||||||
## Camera framing
|
|
||||||
|
|
||||||
Import frames the canonical `camera.state` row from scene bounds by default. Select an exterior or interior framing policy, or preserve the current camera.
|
|
||||||
|
|
||||||
```js
|
|
||||||
await importer.load(url, { framing: "exterior" });
|
|
||||||
await importer.load(url, { framing: "interior" });
|
|
||||||
await importer.load(url, { framing: false });
|
|
||||||
```
|
|
||||||
|
|
||||||
Framing is an addon behavior implemented as a shared camera-row write. It is not a camera subsystem in core.
|
|
||||||
|
|
||||||
## Wrap the result when useful
|
|
||||||
|
|
||||||
Import returns protocol descriptors. Less technical consumers can turn those descriptors into generation-safe objects.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { MeshHandles, MaterialHandles } from "@yawn/mesh-handles";
|
|
||||||
|
|
||||||
const meshes = new MeshHandles(core).fromImportedScene(result);
|
|
||||||
const materials = new MaterialHandles(core).fromImportedScene(result);
|
|
||||||
```
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Package map
|
|
||||||
|
|
||||||
Install only the authoring and convenience layers your application needs. None of the addons is required by core's protocol.
|
|
||||||
|
|
||||||
<div class="package-grid">
|
|
||||||
<a href="./core"><strong>@yawn/core</strong><span>Worker commands, render-graph lifecycle, and shared SOA arrays.</span></a>
|
|
||||||
<a href="./render-graph"><strong>@yawn/render-graph-*</strong><span>Canonical AST plus JSO, fluent, and FXNode frontends.</span></a>
|
|
||||||
<a href="./gltf-import"><strong>@yawn/gltf-import</strong><span>Worker-side glTF parsing directly into shared upload memory.</span></a>
|
|
||||||
<a href="./mesh-handles"><strong>@yawn/mesh-handles</strong><span>Generation-safe mesh, instance, camera, material, and picking facades.</span></a>
|
|
||||||
<a href="../recipes/pipelines"><strong>@yawn/default-pipelines</strong><span>Optional scene WGSL and render/compute declarations.</span></a>
|
|
||||||
<a href="/playground/"><strong>Examples</strong><span>Editable playgrounds that compose the public packages as an application would.</span></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Dependency direction
|
|
||||||
|
|
||||||
Applications create core first, then pass the same `YawnCore` instance to addons. Addons use public commands and shared descriptors; core never imports an addon.
|
|
||||||
|
|
||||||
```text
|
|
||||||
application ─▶ graph frontend ─▶ graph AST
|
|
||||||
│ │
|
|
||||||
├────▶ glTF / handles addons │
|
|
||||||
│ │ │
|
|
||||||
└─────────────┴───────────────▶ core ─▶ render worker
|
|
||||||
```
|
|
||||||
|
|
||||||
This keeps scene policy outside the renderer. You can replace default pipelines, skip conventional handles, or author the AST directly without forking core.
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Conventional handles
|
|
||||||
|
|
||||||
`@yawn/mesh-handles` is an optional object-oriented facade. It never hides core: lifecycle methods call core commands, while frequent mutations write shared rows.
|
|
||||||
|
|
||||||
## Meshes and instances
|
|
||||||
|
|
||||||
Wrap imported descriptors, use the default instance created by glTF, or create another generation-safe instance.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const handles = new MeshHandles(core);
|
|
||||||
const [mesh] = handles.fromImportedScene(imported);
|
|
||||||
|
|
||||||
mesh.defaultInstance.setTransform(matrix);
|
|
||||||
const duplicate = await mesh.createInstance(otherMatrix);
|
|
||||||
duplicate.setType(classificationWords);
|
|
||||||
await duplicate.destroy();
|
|
||||||
```
|
|
||||||
|
|
||||||
The handle is `[slot, generation]`. A stale object cannot modify a slot that has since been reused.
|
|
||||||
|
|
||||||
## Camera and material handles
|
|
||||||
|
|
||||||
The camera and materials look conventional, but property updates are writes to `camera.state` and `material.state`.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const camera = new CameraHandle(core);
|
|
||||||
camera.lookAt([4, 3, 6], [0, 0, 0]);
|
|
||||||
|
|
||||||
const materials = new MaterialHandles(core).fromImportedScene(imported);
|
|
||||||
materials[0].baseColor = [0.2, 0.55, 1, 1];
|
|
||||||
materials[0].roughness = 0.35;
|
|
||||||
```
|
|
||||||
|
|
||||||
<Playground
|
|
||||||
id="conventional-handles"
|
|
||||||
title="Camera and material properties"
|
|
||||||
description="The gallery is reframed and one PBR row is restyled with direct shared-memory writes."
|
|
||||||
/>
|
|
||||||
|
|
||||||
## Worker-side picking
|
|
||||||
|
|
||||||
Picking is lazy. The first `pickRay` starts a separate spatial-query worker over versioned render-data snapshots and returns wrapped instance handles.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const result = await handles.pickRay(origin, direction, {
|
|
||||||
maxDistance: 10_000,
|
|
||||||
maxHits: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const picked = result.hits[0]?.instance;
|
|
||||||
```
|
|
||||||
|
|
||||||
<Playground
|
|
||||||
id="picking"
|
|
||||||
title="Pick the closest shared instance"
|
|
||||||
description="Build the optional BVH and issue a ray query without adding picking code to core."
|
|
||||||
/>
|
|
||||||
|
|
||||||
Call `handles.dispose()` when the scene ends so its optional picking worker and listeners are released.
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# Render graph frontends
|
|
||||||
|
|
||||||
Every frontend ends at `@yawn/render-graph-ast`. Choose the authoring style that fits your tooling; the worker receives the same S-expression either way.
|
|
||||||
|
|
||||||
## Plain-object authoring
|
|
||||||
|
|
||||||
`graphFromObject` validates and freezes ordinary JavaScript data. Pipeline declarations, compute dispatches, nodes, and DAG references all become canonical AST fields.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { graphFromObject } from "@yawn/render-graph-js";
|
|
||||||
|
|
||||||
const graph = graphFromObject({
|
|
||||||
id: "main",
|
|
||||||
revision: 1,
|
|
||||||
pipelines: { render: [scenePipeline], compute: [preparePipeline] },
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: "mesh",
|
|
||||||
state: "enabled",
|
|
||||||
executor: { key: "mesh", version: 2 },
|
|
||||||
parameters: {},
|
|
||||||
inputs: {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
<Playground
|
|
||||||
id="jso-graph"
|
|
||||||
title="A complete JSO graph"
|
|
||||||
description="The playground compiles a plain object through AST serialization and activates the returned loadout."
|
|
||||||
/>
|
|
||||||
|
|
||||||
## Fluent authoring
|
|
||||||
|
|
||||||
Use `RenderGraph` when a small mutable builder makes generated graphs easier to read. Calling `ast()` is the immutable boundary.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { RenderGraph, ref } from "@yawn/render-graph-js";
|
|
||||||
|
|
||||||
const graph = new RenderGraph("generated", 1)
|
|
||||||
.renderPipeline(scenePipeline)
|
|
||||||
.node("source", "mesh", { version: 2 })
|
|
||||||
.node("draw", "scene", {
|
|
||||||
version: 2,
|
|
||||||
inputs: { mesh: [ref("source", "mesh")] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const compiled = await graph.load(core);
|
|
||||||
```
|
|
||||||
|
|
||||||
## FXNode export
|
|
||||||
|
|
||||||
`@yawn/render-graph-fxnode` translates editor snapshots into the same AST. It owns editor catalog versions and diagnostic mapping; no FXNode shape crosses into core.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
|
|
||||||
|
|
||||||
const ast = adaptFxNodeSnapshot(snapshot, revision, {
|
|
||||||
pipelines: myPipelines,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Open the <a href="/render-graph-studio/">Render Graph Studio</a> to edit an FXNode graph, compile it beside the JSO preset, and switch prepared loadouts.
|
|
||||||
|
|
||||||
## DAG references
|
|
||||||
|
|
||||||
A reference is data, not a nested expression. Point several consumers at one output to represent fan-out without repeating the source node.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { reference } from "@yawn/render-graph-ast";
|
|
||||||
|
|
||||||
const shared = reference("sceneColor", "texture");
|
|
||||||
left.inputs.color = [shared];
|
|
||||||
right.inputs.color = [shared];
|
|
||||||
```
|
|
||||||
|
|
||||||
The serializer emits `(ref "sceneColor" "texture")` wherever that edge is consumed.
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Minimal playground
|
||||||
|
|
||||||
|
This is the one runnable example. It allocates a single `f32` row, sends an externally authored JSO render graph through the AST codec, and changes color by writing the shared row directly on pointer movement.
|
||||||
|
|
||||||
|
<Playground />
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Playground from "./.vitepress/Playground.vue";
|
||||||
|
</script>
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# Graph authoring recipes
|
|
||||||
|
|
||||||
All three authoring styles produce the same canonical immutable AST.
|
|
||||||
|
|
||||||
## 01 — Canonical DAG AST
|
|
||||||
|
|
||||||
Create references separately from nodes. Reusing `shared` makes one output fan out to two consumers.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { createGraphAst, reference, serializeGraphAst } from "@yawn/render-graph-ast";
|
|
||||||
|
|
||||||
const expression = (id, inputs = {}) => ({
|
|
||||||
id,
|
|
||||||
state: "enabled",
|
|
||||||
executor: { key: "and", version: 2 },
|
|
||||||
parameters: {},
|
|
||||||
inputs,
|
|
||||||
});
|
|
||||||
const shared = reference("source", "value");
|
|
||||||
const ast = createGraphAst({
|
|
||||||
id: "shared_dag",
|
|
||||||
revision: 1,
|
|
||||||
nodes: [
|
|
||||||
expression("source"),
|
|
||||||
expression("left", { inputs: [shared] }),
|
|
||||||
expression("right", { inputs: [shared] }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const source = serializeGraphAst(ast);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 02 — Plain JavaScript object graph
|
|
||||||
|
|
||||||
Let `@yawn/render-graph-js` canonicalize an ordinary object when application code does not need to manipulate AST internals.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { graphFromObject } from "@yawn/render-graph-js";
|
|
||||||
|
|
||||||
const graph = graphFromObject({
|
|
||||||
id: "jso_graph",
|
|
||||||
revision: 1,
|
|
||||||
nodes: [{
|
|
||||||
id: "mesh",
|
|
||||||
state: "enabled",
|
|
||||||
executor: { key: "mesh", version: 2 },
|
|
||||||
parameters: {},
|
|
||||||
inputs: {},
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
<Playground id="jso-graph" title="Compile a complete JSO graph" />
|
|
||||||
|
|
||||||
## 03 — Fluent graph builder
|
|
||||||
|
|
||||||
Use the chainable facade for generated graphs, then call `ast()` or `load(core)` at the boundary.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { RenderGraph, ref } from "@yawn/render-graph-js";
|
|
||||||
|
|
||||||
const ast = new RenderGraph("fluent_graph", 1)
|
|
||||||
.node("source", "and", { version: 2 })
|
|
||||||
.node("consumer", "not", {
|
|
||||||
inputs: { operand: [ref("source", "value")] },
|
|
||||||
})
|
|
||||||
.ast();
|
|
||||||
```
|
|
||||||
|
|
||||||
## 04 — Export an FXNode snapshot
|
|
||||||
|
|
||||||
Keep editor schemas in the FXNode addon. Attach external pipelines during export so the resulting AST is a self-contained loadout description.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { defaultPipelines } from "@yawn/default-pipelines";
|
|
||||||
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
|
|
||||||
|
|
||||||
const ast = adaptFxNodeSnapshot(snapshot, 1, {
|
|
||||||
pipelines: defaultPipelines,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the <a href="/render-graph-studio/">Render Graph Studio</a> for the interactive FXNode version of this recipe.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# Recipes
|
|
||||||
|
|
||||||
The old source-only cookbook now lives here as guided, copyable snippets. Open an attached playground when you want a complete browser context and live renderer.
|
|
||||||
|
|
||||||
<div class="package-grid">
|
|
||||||
<a href="./graph-authoring"><strong>01–04 · Graph authoring</strong><span>Canonical AST, JSO, fluent builder, and FXNode export.</span></a>
|
|
||||||
<a href="./pipelines"><strong>05–08 · Pipelines and loadouts</strong><span>Default programs, custom render/compute WGSL, and activation.</span></a>
|
|
||||||
<a href="./render-data"><strong>09–11 · Assets and render data</strong><span>glTF import, mesh instances, and custom SOA columns.</span></a>
|
|
||||||
<a href="./runtime"><strong>12–17 · Runtime interaction</strong><span>SAB animation, picking, worker clients, scenes, camera, and materials.</span></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
Recipes 01–07 intentionally demonstrate graph fragments. A renderable loadout also needs compatible resource, scene, and frame-output nodes. Recipe 15 and the playgrounds show complete composition.
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# Pipeline and loadout recipes
|
|
||||||
|
|
||||||
WGSL belongs to a graph package or your application. Core contains no scene program.
|
|
||||||
|
|
||||||
## 05 — Attach the default pipelines
|
|
||||||
|
|
||||||
The optional package exports plain declarations, so copy only the programs your graph uses.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { defaultPipelines } from "@yawn/default-pipelines";
|
|
||||||
import { RenderGraph } from "@yawn/render-graph-js";
|
|
||||||
|
|
||||||
const graph = new RenderGraph("default_programs", 1);
|
|
||||||
for (const pipeline of defaultPipelines.render) {
|
|
||||||
graph.renderPipeline(pipeline);
|
|
||||||
}
|
|
||||||
for (const pipeline of defaultPipelines.compute) {
|
|
||||||
graph.computePipeline(pipeline);
|
|
||||||
}
|
|
||||||
const ast = graph.ast();
|
|
||||||
```
|
|
||||||
|
|
||||||
## 06 — Supply a custom render pipeline
|
|
||||||
|
|
||||||
Put source and entry points in the graph declaration. The shader must honor the scene ABI expected by the executor that uses it.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const shader = /* wgsl */ `
|
|
||||||
@group(1) @binding(0) var<uniform> view_projection: mat4x4<f32>;
|
|
||||||
struct Input {
|
|
||||||
@location(0) position: vec3<f32>,
|
|
||||||
@location(3) model_0: vec4<f32>,
|
|
||||||
@location(4) model_1: vec4<f32>,
|
|
||||||
@location(5) model_2: vec4<f32>,
|
|
||||||
@location(6) model_3: vec4<f32>,
|
|
||||||
}
|
|
||||||
@vertex fn vertex_main(input: Input) -> @builtin(position) vec4<f32> {
|
|
||||||
let model = mat4x4<f32>(
|
|
||||||
input.model_0,
|
|
||||||
input.model_1,
|
|
||||||
input.model_2,
|
|
||||||
input.model_3,
|
|
||||||
);
|
|
||||||
return view_projection * model * vec4(input.position, 1.0);
|
|
||||||
}
|
|
||||||
@fragment fn fragment_main() -> @location(0) vec4<f32> {
|
|
||||||
return vec4(0.2, 0.7, 1.0, 1.0);
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const ast = new RenderGraph("custom_render_program", 1)
|
|
||||||
.renderPipeline({
|
|
||||||
name: "scene",
|
|
||||||
shader,
|
|
||||||
vertexEntry: "vertex_main",
|
|
||||||
fragmentEntry: "fragment_main",
|
|
||||||
doubleSided: false,
|
|
||||||
})
|
|
||||||
.ast();
|
|
||||||
```
|
|
||||||
|
|
||||||
## 07 — Supply a compute pipeline
|
|
||||||
|
|
||||||
Dispatch dimensions are graph data and are allocated with the rest of the loadout.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const shader = /* wgsl */ `
|
|
||||||
@compute @workgroup_size(8, 1, 1)
|
|
||||||
fn initialize() {}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ast = new RenderGraph("compute_program", 1)
|
|
||||||
.computePipeline({
|
|
||||||
name: "initialize",
|
|
||||||
shader,
|
|
||||||
entry: "initialize",
|
|
||||||
dispatch: [4, 1, 1],
|
|
||||||
})
|
|
||||||
.ast();
|
|
||||||
```
|
|
||||||
|
|
||||||
## 08 — Compile and switch
|
|
||||||
|
|
||||||
Compile first, then activate the prepared ID. Drop a candidate when your surrounding transaction fails.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { loadGraph } from "@yawn/render-graph-js";
|
|
||||||
|
|
||||||
const compiled = await loadGraph(core, graph);
|
|
||||||
try {
|
|
||||||
await core.switchCompiledGraph(compiled.compiledId);
|
|
||||||
} catch (error) {
|
|
||||||
await core.dropCompiledGraph(compiled.compiledId).catch(() => {});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
<Playground
|
|
||||||
id="jso-graph"
|
|
||||||
title="Compile and activate a pipeline loadout"
|
|
||||||
description="The full preset contains external render/compute declarations and a transient resource graph."
|
|
||||||
/>
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Asset and render-data recipes
|
|
||||||
|
|
||||||
Bulk data moves through shared storage. Small descriptors and lifecycle decisions move through messages.
|
|
||||||
|
|
||||||
## 09 — Import glTF in a worker
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { GltfImporter } from "@yawn/gltf-import";
|
|
||||||
import { MeshHandles } from "@yawn/mesh-handles";
|
|
||||||
|
|
||||||
const importer = new GltfImporter(core);
|
|
||||||
try {
|
|
||||||
const imported = await importer.load(url);
|
|
||||||
const meshes = new MeshHandles(core).fromImportedScene(imported);
|
|
||||||
} finally {
|
|
||||||
importer.dispose();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
<Playground id="gltf-worker" title="Shared-memory glTF import" />
|
|
||||||
|
|
||||||
## 10 — Create and mutate mesh instances
|
|
||||||
|
|
||||||
Creating or destroying an instance is lifecycle communication. Mutating an existing transform or type is a generation-guarded shared write.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const identity = [
|
|
||||||
1, 0, 0, 0,
|
|
||||||
0, 1, 0, 0,
|
|
||||||
0, 0, 1, 0,
|
|
||||||
0, 0, 0, 1,
|
|
||||||
];
|
|
||||||
|
|
||||||
const instance = await mesh.createInstance(identity);
|
|
||||||
instance.setTransform(nextTransform);
|
|
||||||
instance.setType(sixteenU32Words);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 11 — Add a custom SOA column
|
|
||||||
|
|
||||||
Select the instance domain to keep row count aligned with instance capacity. Use four lanes for one SIMD-width velocity row.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const velocity = await core.allocateArray({
|
|
||||||
name: "instance.velocity",
|
|
||||||
domain: "instance",
|
|
||||||
scalar: "f32",
|
|
||||||
lanes: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
velocity.write(instance.handle[0], [x, y, z, 0]);
|
|
||||||
```
|
|
||||||
|
|
||||||
<Playground id="custom-soa" title="Allocate instance velocity data" />
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# Runtime interaction recipes
|
|
||||||
|
|
||||||
Once render data exists, keep hot updates on shared rows and leave core free of application policy.
|
|
||||||
|
|
||||||
## 12 — Animate directly through the SAB
|
|
||||||
|
|
||||||
The instance facade performs the live-generation check and writes `instance.transform`.
|
|
||||||
|
|
||||||
```js
|
|
||||||
function frame(time) {
|
|
||||||
instance.setTransform(rotationY(time * 0.001));
|
|
||||||
requestAnimationFrame(frame);
|
|
||||||
}
|
|
||||||
requestAnimationFrame(frame);
|
|
||||||
```
|
|
||||||
|
|
||||||
<Playground id="shared-animation" title="Frame-rate transform writes" />
|
|
||||||
|
|
||||||
## 13 — Pick through the optional BVH worker
|
|
||||||
|
|
||||||
```js
|
|
||||||
const result = await meshHandles.pickRay(origin, direction, {
|
|
||||||
maxDistance: 10_000,
|
|
||||||
maxHits: 1,
|
|
||||||
});
|
|
||||||
const nearest = result.hits[0]?.instance;
|
|
||||||
```
|
|
||||||
|
|
||||||
The picking addon consumes versioned shared snapshots. Core does not know about rays or BVHs.
|
|
||||||
|
|
||||||
<Playground id="picking" title="Pick a shared instance" />
|
|
||||||
|
|
||||||
## 14 — Connect worker to worker
|
|
||||||
|
|
||||||
`MessagePort` implements the Worker-like methods the core client needs. Start it through the normal `YawnCore` constructor.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { YawnCore } from "@yawn/core";
|
|
||||||
|
|
||||||
const core = new YawnCore({
|
|
||||||
worker: port,
|
|
||||||
memory,
|
|
||||||
ringPtr,
|
|
||||||
free: () => port.close(),
|
|
||||||
});
|
|
||||||
await core.ready;
|
|
||||||
```
|
|
||||||
|
|
||||||
This is why “main thread” is not an architectural role in Yawn: any browser worker can own the client.
|
|
||||||
|
|
||||||
## 15 — Compose a complete scene
|
|
||||||
|
|
||||||
Use one core instance for every addon and activate the graph only after its complete loadout has compiled.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const importer = new GltfImporter(core);
|
|
||||||
const imported = await importer.load(gltfUrl);
|
|
||||||
const meshes = new MeshHandles(core).fromImportedScene(imported);
|
|
||||||
const compiled = await loadGraph(core, completeGraph);
|
|
||||||
await core.switchCompiledGraph(compiled.compiledId);
|
|
||||||
```
|
|
||||||
|
|
||||||
<Playground id="first-scene" title="Complete addon composition" />
|
|
||||||
|
|
||||||
## 16 — Treat camera input as render data
|
|
||||||
|
|
||||||
There is no camera API in core. Read and write the canonical 16-lane row directly from controls or simulation code.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const camera = core.array("camera.state");
|
|
||||||
const state = camera.read(0);
|
|
||||||
state.splice(0, 3, ...nextEye);
|
|
||||||
camera.write(0, state);
|
|
||||||
```
|
|
||||||
|
|
||||||
The row packs eye, target, up, field of view, aspect, near, and far values into 64 bytes.
|
|
||||||
|
|
||||||
## 17 — Use conventional camera and material properties
|
|
||||||
|
|
||||||
Choose addon handles when a property-oriented workflow is more useful than raw SOA rows.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const camera = new CameraHandle(core);
|
|
||||||
const materials = new MaterialHandles(core).fromImportedScene(imported);
|
|
||||||
|
|
||||||
camera.lookAt([4, 3, 6], [0, 0, 0]);
|
|
||||||
materials[0].baseColor = [0.2, 0.55, 1, 1];
|
|
||||||
materials[0].roughness = 0.35;
|
|
||||||
```
|
|
||||||
|
|
||||||
<Playground id="conventional-handles" title="Camera and material handles" />
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Yawn examples
|
|
||||||
|
|
||||||
- `playground/` is the editable code-and-preview environment used by the docs.
|
|
||||||
- `render-graph-studio/` is the advanced FXNode and JSO graph playground.
|
|
||||||
- `shared/` contains browser bootstrap utilities shared by those playgrounds.
|
|
||||||
|
|
||||||
Start the playgrounds and VitePress tutorials together with `npm run examples`.
|
|
||||||
Copyable recipes live under `docs/recipes`; the example server contains only
|
|
||||||
runnable playground code.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<meta http-equiv="refresh" content="0;url=/playground/" />
|
|
||||||
<title>Yawn Playground</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p><a href="/playground/">Open the Yawn Playground</a></p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<meta name="description" content="Edit and run Yawn render graph examples." />
|
|
||||||
<title>Yawn Playground</title>
|
|
||||||
<link rel="stylesheet" href="./styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<a class="brand" href="/docs/">YAWN<span>.</span></a>
|
|
||||||
<div class="recipe-meta">
|
|
||||||
<strong id="recipe-title">Playground</strong>
|
|
||||||
<span id="recipe-package"></span>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
<span>Example</span>
|
|
||||||
<select id="recipe-select" aria-label="Playground example"></select>
|
|
||||||
</label>
|
|
||||||
<button id="run" class="primary" type="button">▶ Run</button>
|
|
||||||
<button id="reset" type="button">Reset</button>
|
|
||||||
<button id="copy" type="button">Copy link</button>
|
|
||||||
<a id="docs-link" class="button" href="/docs/">Docs ↗</a>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<section class="code-pane" aria-label="Code editor">
|
|
||||||
<div class="pane-title"><span>JavaScript</span><kbd>Ctrl</kbd> + <kbd>Enter</kbd> to run</div>
|
|
||||||
<textarea id="editor" spellcheck="false" aria-label="Playground code"></textarea>
|
|
||||||
</section>
|
|
||||||
<section class="preview-pane" aria-label="Live preview">
|
|
||||||
<iframe id="preview" title="Yawn playground preview"></iframe>
|
|
||||||
<output id="status" aria-live="polite">Preparing playground…</output>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<script type="module" src="./index.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { PLAYGROUND_RECIPES, playgroundRecipe } from "./recipes.js";
|
|
||||||
|
|
||||||
const select = document.querySelector("#recipe-select");
|
|
||||||
const title = document.querySelector("#recipe-title");
|
|
||||||
const packageName = document.querySelector("#recipe-package");
|
|
||||||
const editor = document.querySelector("#editor");
|
|
||||||
const preview = document.querySelector("#preview");
|
|
||||||
const status = document.querySelector("#status");
|
|
||||||
const docs = document.querySelector("#docs-link");
|
|
||||||
let recipeId;
|
|
||||||
|
|
||||||
for (const [id, recipe] of Object.entries(PLAYGROUND_RECIPES)) {
|
|
||||||
const option = document.createElement("option");
|
|
||||||
option.value = id;
|
|
||||||
option.textContent = recipe.title;
|
|
||||||
select.append(option);
|
|
||||||
}
|
|
||||||
|
|
||||||
function choose(id, updateUrl = true) {
|
|
||||||
recipeId = PLAYGROUND_RECIPES[id] ? id : "first-scene";
|
|
||||||
const recipe = playgroundRecipe(recipeId);
|
|
||||||
select.value = recipeId;
|
|
||||||
title.textContent = recipe.title;
|
|
||||||
packageName.textContent = recipe.package;
|
|
||||||
editor.value = recipe.source;
|
|
||||||
docs.href = recipe.docs;
|
|
||||||
status.textContent = recipe.description;
|
|
||||||
if (updateUrl) {
|
|
||||||
const url = new URL(location.href);
|
|
||||||
url.searchParams.set("recipe", recipeId);
|
|
||||||
history.replaceState(null, "", url);
|
|
||||||
}
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
|
|
||||||
function run() {
|
|
||||||
status.textContent = "Running…";
|
|
||||||
status.dataset.error = "false";
|
|
||||||
preview.src = `./runner.html?recipe=${encodeURIComponent(recipeId)}&run=${Date.now()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
addEventListener("message", (event) => {
|
|
||||||
if (event.origin !== location.origin || event.source !== preview.contentWindow) return;
|
|
||||||
if (event.data?.type === "playground-runner-ready") {
|
|
||||||
preview.contentWindow.postMessage(
|
|
||||||
{ type: "playground-run", source: editor.value },
|
|
||||||
location.origin,
|
|
||||||
);
|
|
||||||
} else if (event.data?.type === "playground-status" || event.data?.type === "playground-error") {
|
|
||||||
status.textContent = event.data.message;
|
|
||||||
status.dataset.error = String(event.data.type === "playground-error");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelector("#run").addEventListener("click", run);
|
|
||||||
document.querySelector("#reset").addEventListener("click", () => {
|
|
||||||
editor.value = playgroundRecipe(recipeId).source;
|
|
||||||
run();
|
|
||||||
});
|
|
||||||
document.querySelector("#copy").addEventListener("click", async (event) => {
|
|
||||||
await navigator.clipboard.writeText(location.href);
|
|
||||||
const original = event.currentTarget.textContent;
|
|
||||||
event.currentTarget.textContent = "Copied";
|
|
||||||
setTimeout(() => { event.currentTarget.textContent = original; }, 1200);
|
|
||||||
});
|
|
||||||
select.addEventListener("change", () => choose(select.value));
|
|
||||||
editor.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
|
||||||
event.preventDefault();
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
if (event.key === "Tab") {
|
|
||||||
event.preventDefault();
|
|
||||||
const start = editor.selectionStart;
|
|
||||||
editor.setRangeText(" ", start, editor.selectionEnd, "end");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
choose(new URLSearchParams(location.search).get("recipe"), false);
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
export const PLAYGROUND_RECIPES = Object.freeze({
|
|
||||||
"first-scene": Object.freeze({
|
|
||||||
title: "Your first scene",
|
|
||||||
package: "All packages",
|
|
||||||
description: "Boot core, load procedural glTF through the import worker, and activate a graph.",
|
|
||||||
docs: "/docs/guide/first-scene",
|
|
||||||
source: `const scene = await yawn.createScene({ loadout: "cubes" });
|
|
||||||
yawn.status(
|
|
||||||
\`Ready · \${scene.meshes.length} meshes · \${scene.core.telemetry.draws} draws\`,
|
|
||||||
);`,
|
|
||||||
}),
|
|
||||||
"jso-graph": Object.freeze({
|
|
||||||
title: "Load a JSO render graph",
|
|
||||||
package: "@yawn/render-graph-js",
|
|
||||||
description: "Compile a plain-object graph through the canonical AST boundary.",
|
|
||||||
docs: "/docs/packages/render-graph#plain-object-authoring",
|
|
||||||
source: `const scene = await yawn.createScene({
|
|
||||||
loadout: "spheres",
|
|
||||||
graph: yawn.graphs.culling,
|
|
||||||
});
|
|
||||||
yawn.status(
|
|
||||||
\`Graph \${scene.compiled.graphId} · \${scene.core.telemetry.draws} draws\`,
|
|
||||||
);`,
|
|
||||||
}),
|
|
||||||
"gltf-worker": Object.freeze({
|
|
||||||
title: "Import glTF in a worker",
|
|
||||||
package: "@yawn/gltf-import",
|
|
||||||
description: "Stage a generated GLB in shared memory and commit only metadata.",
|
|
||||||
docs: "/docs/packages/gltf-import",
|
|
||||||
source: `const scene = await yawn.createScene({ loadout: "spheres" });
|
|
||||||
yawn.status(
|
|
||||||
\`Imported \${scene.meshes.length} mesh handles through shared memory\`,
|
|
||||||
);`,
|
|
||||||
}),
|
|
||||||
"shared-animation": Object.freeze({
|
|
||||||
title: "Animate through the SAB",
|
|
||||||
package: "@yawn/core",
|
|
||||||
description: "Write a generation-guarded instance transform every frame without messages.",
|
|
||||||
docs: "/docs/packages/core#fast-path-shared-writes",
|
|
||||||
source: `const scene = await yawn.createScene({ loadout: "cubes" });
|
|
||||||
const instance = scene.meshes[0].defaultInstance;
|
|
||||||
const start = performance.now();
|
|
||||||
|
|
||||||
function animate(now) {
|
|
||||||
const angle = (now - start) * 0.001;
|
|
||||||
instance.setTransform(yawn.rotationY(angle));
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
}
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
yawn.status("Animating instance.transform directly in shared memory");`,
|
|
||||||
}),
|
|
||||||
"custom-soa": Object.freeze({
|
|
||||||
title: "Allocate custom render data",
|
|
||||||
package: "@yawn/core",
|
|
||||||
description: "Add one aligned velocity row for every instance slot.",
|
|
||||||
docs: "/docs/packages/core#request-an-soa-column",
|
|
||||||
source: `const scene = await yawn.createScene({ loadout: "cubes" });
|
|
||||||
const velocity = await scene.core.allocateArray({
|
|
||||||
name: "instance.velocity",
|
|
||||||
domain: "instance",
|
|
||||||
scalar: "f32",
|
|
||||||
lanes: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const mesh of scene.meshes) {
|
|
||||||
velocity.write(mesh.defaultInstance.handle[0], [0, 0.25, 0, 0]);
|
|
||||||
}
|
|
||||||
yawn.status(\`Allocated \${velocity.length} SIMD-aligned velocity rows\`);`,
|
|
||||||
}),
|
|
||||||
"conventional-handles": Object.freeze({
|
|
||||||
title: "Camera and material handles",
|
|
||||||
package: "@yawn/mesh-handles",
|
|
||||||
description: "Use familiar properties while mutations remain direct shared-memory writes.",
|
|
||||||
docs: "/docs/packages/mesh-handles#camera-and-material-handles",
|
|
||||||
source: `const scene = await yawn.createScene({ loadout: "materials" });
|
|
||||||
scene.camera.lookAt([11, 9, 13], [0, 0, 0]);
|
|
||||||
|
|
||||||
const material = scene.materials[1];
|
|
||||||
material.baseColor = [0.1, 0.55, 1, 1];
|
|
||||||
material.metallic = 0.15;
|
|
||||||
material.roughness = 0.28;
|
|
||||||
yawn.status("Camera and material properties committed through SAB rows");`,
|
|
||||||
}),
|
|
||||||
picking: Object.freeze({
|
|
||||||
title: "Pick shared scene data",
|
|
||||||
package: "@yawn/mesh-handles",
|
|
||||||
description: "Build the optional worker-side BVH and query the closest instance.",
|
|
||||||
docs: "/docs/packages/mesh-handles#worker-side-picking",
|
|
||||||
source: `const scene = await yawn.createScene({ loadout: "cubes" });
|
|
||||||
const state = scene.camera.state;
|
|
||||||
const origin = state.slice(0, 3);
|
|
||||||
const direction = state.slice(4, 7).map((value, axis) => value - origin[axis]);
|
|
||||||
const result = await scene.handles.pickRay(origin, direction, { maxHits: 1 });
|
|
||||||
yawn.status(
|
|
||||||
result.hits.length
|
|
||||||
? \`Picked instance \${result.hits[0].instance.handle.join(":")}\`
|
|
||||||
: "No instance intersected the center ray",
|
|
||||||
);`,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function playgroundRecipe(id) {
|
|
||||||
return PLAYGROUND_RECIPES[id] ?? PLAYGROUND_RECIPES["first-scene"];
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<title>Yawn Playground Runner</title>
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html, body, main, canvas { width: 100%; height: 100%; margin: 0; }
|
|
||||||
body { overflow: hidden; background: #080b10; color: #e8edf6; font: 13px Inter, system-ui, sans-serif; }
|
|
||||||
canvas { display: block; background: #080b10; }
|
|
||||||
output { position: fixed; left: 14px; bottom: 14px; max-width: calc(100% - 28px); padding: 8px 11px; border: 1px solid #ffffff18; border-radius: 7px; background: #0d121bea; color: #b9c3d2; box-shadow: 0 8px 28px #0008; }
|
|
||||||
body[data-embed="false"] output { display: none; }
|
|
||||||
body[data-error="true"] output { border-color: #ef795d88; color: #ffb8a6; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main><canvas id="scene"></canvas><output id="status">Waiting for code…</output></main>
|
|
||||||
<script type="module" src="./runner.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { createPlaygroundRuntime } from "./runtime.js";
|
|
||||||
import { playgroundRecipe } from "./recipes.js";
|
|
||||||
|
|
||||||
const status = document.querySelector("#status");
|
|
||||||
const canvas = document.querySelector("#scene");
|
|
||||||
const parentOrigin = location.origin;
|
|
||||||
const parameters = new URLSearchParams(location.search);
|
|
||||||
const embedded = parameters.has("embed");
|
|
||||||
document.body.dataset.embed = String(embedded);
|
|
||||||
let started = false;
|
|
||||||
|
|
||||||
function report(message, error = false) {
|
|
||||||
status.textContent = message;
|
|
||||||
document.body.dataset.error = String(error);
|
|
||||||
parent.postMessage({ type: error ? "playground-error" : "playground-status", message }, parentOrigin);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function execute(source) {
|
|
||||||
if (started) return;
|
|
||||||
started = true;
|
|
||||||
try {
|
|
||||||
const yawn = createPlaygroundRuntime(canvas, report);
|
|
||||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
||||||
await new AsyncFunction("yawn", `"use strict";\n${source}`)(yawn);
|
|
||||||
document.documentElement.dataset.yawnReady = "true";
|
|
||||||
parent.postMessage({ type: "playground-ready" }, parentOrigin);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
report(error?.stack ?? error?.message ?? String(error), true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addEventListener("message", (event) => {
|
|
||||||
if (event.origin !== parentOrigin || event.data?.type !== "playground-run") return;
|
|
||||||
void execute(event.data.source);
|
|
||||||
});
|
|
||||||
|
|
||||||
const recipeId = parameters.get("recipe");
|
|
||||||
if (embedded) {
|
|
||||||
void execute(playgroundRecipe(recipeId).source);
|
|
||||||
} else {
|
|
||||||
parent.postMessage({ type: "playground-runner-ready" }, parentOrigin);
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { YawnCore } from "@yawn/core";
|
|
||||||
import { GltfImporter } from "@yawn/gltf-import";
|
|
||||||
import {
|
|
||||||
CameraHandle,
|
|
||||||
MaterialHandles,
|
|
||||||
MeshHandles,
|
|
||||||
} from "@yawn/mesh-handles";
|
|
||||||
import { loadGraph, graphFromObject, RenderGraph, ref } from "@yawn/render-graph-js";
|
|
||||||
import {
|
|
||||||
createGraphAst,
|
|
||||||
reference,
|
|
||||||
serializeGraphAst,
|
|
||||||
} from "@yawn/render-graph-ast";
|
|
||||||
import { defaultPipelines } from "@yawn/default-pipelines";
|
|
||||||
import { loadDemoLoadout } from "../render-graph-studio/demo-loadouts.js";
|
|
||||||
import { culling } from "../render-graph-studio/render-graph/presets.js";
|
|
||||||
import { installCameraRenderDataControls } from "../shared/camera-controls.js";
|
|
||||||
import { createWorkerTransport } from "../shared/create-worker-transport.js";
|
|
||||||
|
|
||||||
const waitForFrame = (core, predicate, timeout = 30_000) =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const current = core.telemetry;
|
|
||||||
if (current && predicate(current)) {
|
|
||||||
resolve(current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
core.removeEventListener("renderer-frame", frame);
|
|
||||||
reject(new Error("Renderer confirmation timed out"));
|
|
||||||
}, timeout);
|
|
||||||
const frame = (event) => {
|
|
||||||
if (!predicate(event.detail)) return;
|
|
||||||
clearTimeout(timer);
|
|
||||||
core.removeEventListener("renderer-frame", frame);
|
|
||||||
resolve(event.detail);
|
|
||||||
};
|
|
||||||
core.addEventListener("renderer-frame", frame);
|
|
||||||
});
|
|
||||||
|
|
||||||
export function rotationY(angle) {
|
|
||||||
const cosine = Math.cos(angle);
|
|
||||||
const sine = Math.sin(angle);
|
|
||||||
return [
|
|
||||||
cosine, 0, -sine, 0,
|
|
||||||
0, 1, 0, 0,
|
|
||||||
sine, 0, cosine, 0,
|
|
||||||
0, 0, 0, 1,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPlaygroundRuntime(canvas, report) {
|
|
||||||
let activeScene;
|
|
||||||
const status = (message) => {
|
|
||||||
report(String(message));
|
|
||||||
};
|
|
||||||
|
|
||||||
async function createScene({ loadout = "cubes", graph = culling } = {}) {
|
|
||||||
activeScene?.dispose();
|
|
||||||
const core = new YawnCore(createWorkerTransport(canvas));
|
|
||||||
const handles = new MeshHandles(core);
|
|
||||||
const importer = new GltfImporter(core);
|
|
||||||
let stopControls = () => {};
|
|
||||||
try {
|
|
||||||
status("Starting render worker…");
|
|
||||||
await core.ready;
|
|
||||||
const compiled = await loadGraph(core, graph);
|
|
||||||
const targetRevision = (core.telemetry?.revision ?? 0) + 1;
|
|
||||||
const glb = await loadDemoLoadout(loadout);
|
|
||||||
const url = URL.createObjectURL(
|
|
||||||
new Blob([glb], { type: "model/gltf-binary" }),
|
|
||||||
);
|
|
||||||
let imported;
|
|
||||||
try {
|
|
||||||
imported = await importer.load(url);
|
|
||||||
} finally {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
const meshes = handles.fromImportedScene(imported);
|
|
||||||
const materials = new MaterialHandles(core).fromImportedScene(imported);
|
|
||||||
const camera = new CameraHandle(core);
|
|
||||||
stopControls = installCameraRenderDataControls(core, canvas);
|
|
||||||
await core.switchCompiledGraph(compiled.compiledId);
|
|
||||||
await waitForFrame(
|
|
||||||
core,
|
|
||||||
(frame) =>
|
|
||||||
frame.revision === targetRevision &&
|
|
||||||
frame.activeCompiledGraph === graph.id &&
|
|
||||||
frame.draws > 0 &&
|
|
||||||
frame.gpuError === false,
|
|
||||||
);
|
|
||||||
activeScene = {
|
|
||||||
core,
|
|
||||||
handles,
|
|
||||||
meshes,
|
|
||||||
materials,
|
|
||||||
camera,
|
|
||||||
compiled: { ...compiled, graphId: graph.id },
|
|
||||||
dispose() {
|
|
||||||
stopControls();
|
|
||||||
handles.dispose();
|
|
||||||
core.dispose();
|
|
||||||
activeScene = undefined;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return activeScene;
|
|
||||||
} catch (error) {
|
|
||||||
stopControls();
|
|
||||||
handles.dispose();
|
|
||||||
core.dispose();
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
importer.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.freeze({
|
|
||||||
createScene,
|
|
||||||
status,
|
|
||||||
rotationY,
|
|
||||||
graphs: Object.freeze({ culling }),
|
|
||||||
packages: Object.freeze({
|
|
||||||
createGraphAst,
|
|
||||||
defaultPipelines,
|
|
||||||
graphFromObject,
|
|
||||||
reference,
|
|
||||||
ref,
|
|
||||||
RenderGraph,
|
|
||||||
serializeGraphAst,
|
|
||||||
}),
|
|
||||||
dispose() {
|
|
||||||
activeScene?.dispose();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
:root {
|
|
||||||
color-scheme: dark;
|
|
||||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
|
||||||
background: #0a0d12;
|
|
||||||
color: #eef2f8;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html, body { height: 100%; margin: 0; overflow: hidden; }
|
|
||||||
body { display: grid; grid-template-rows: 64px minmax(0, 1fr); }
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 9px 14px;
|
|
||||||
border-bottom: 1px solid #29303b;
|
|
||||||
background: #11151c;
|
|
||||||
}
|
|
||||||
.brand { margin-right: 4px; color: #fff; font-size: 17px; font-weight: 850; letter-spacing: .14em; text-decoration: none; }
|
|
||||||
.brand span { color: #e16f3c; }
|
|
||||||
.recipe-meta { display: grid; min-width: 190px; margin-right: auto; }
|
|
||||||
.recipe-meta strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.recipe-meta span, label > span { color: #8792a3; font: 10px ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; }
|
|
||||||
label { display: grid; gap: 3px; }
|
|
||||||
button, select, .button {
|
|
||||||
min-height: 36px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid #3a4452;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #e8edf5;
|
|
||||||
background: #202733;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.button { display: inline-flex; align-items: center; }
|
|
||||||
button:hover, select:hover, .button:hover { border-color: #69778c; }
|
|
||||||
button.primary { border-color: #ee8251; background: #c65d2f; }
|
|
||||||
main { display: grid; grid-template-columns: minmax(360px, .9fr) minmax(0, 1.1fr); min-height: 0; }
|
|
||||||
.code-pane, .preview-pane { position: relative; min-width: 0; min-height: 0; }
|
|
||||||
.code-pane { display: grid; grid-template-rows: 38px minmax(0, 1fr); border-right: 1px solid #29303b; background: #0d1117; }
|
|
||||||
.pane-title { display: flex; align-items: center; gap: 5px; padding: 0 14px; border-bottom: 1px solid #252c36; color: #8894a5; font-size: 11px; }
|
|
||||||
.pane-title span { margin-right: auto; color: #d7dee9; font-weight: 700; }
|
|
||||||
kbd { padding: 2px 5px; border: 1px solid #39424e; border-radius: 4px; background: #171d25; font: 10px ui-monospace, monospace; }
|
|
||||||
textarea {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
resize: none;
|
|
||||||
padding: 22px;
|
|
||||||
border: 0;
|
|
||||||
outline: 0;
|
|
||||||
color: #dce6f3;
|
|
||||||
background: transparent;
|
|
||||||
font: 14px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
tab-size: 2;
|
|
||||||
}
|
|
||||||
.preview-pane { background: #080b10; }
|
|
||||||
iframe { width: 100%; height: 100%; border: 0; }
|
|
||||||
#status { position: absolute; left: 14px; bottom: 14px; max-width: calc(100% - 28px); overflow: hidden; padding: 8px 11px; border: 1px solid #ffffff18; border-radius: 7px; background: #0d121bea; color: #b9c3d2; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; }
|
|
||||||
#status[data-error="true"] { color: #ffb8a6; border-color: #ef795d88; }
|
|
||||||
@media (max-width: 850px) {
|
|
||||||
body { grid-template-rows: auto minmax(0, 1fr); }
|
|
||||||
header { flex-wrap: wrap; }
|
|
||||||
.recipe-meta { min-width: 0; }
|
|
||||||
header label { order: 2; width: 100%; }
|
|
||||||
header select { width: 100%; }
|
|
||||||
main { grid-template-columns: 1fr; grid-template-rows: 48% 52%; }
|
|
||||||
.code-pane { border-right: 0; border-bottom: 1px solid #29303b; }
|
|
||||||
#copy { display: none; }
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
// Procedural example assets keep the package demo self-contained.
|
|
||||||
const JSON_CHUNK = 0x4e4f534a;
|
|
||||||
const BIN_CHUNK = 0x004e4942;
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
|
|
||||||
const align4 = value => (value + 3) & ~3;
|
|
||||||
const finiteMinMax = (values, width) => {
|
|
||||||
const min = Array(width).fill(Infinity), max = Array(width).fill(-Infinity);
|
|
||||||
for (let i=0;i<values.length;i++) { const lane=i%width; min[lane]=Math.min(min[lane],values[i]); max[lane]=Math.max(max[lane],values[i]); }
|
|
||||||
return {min,max};
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Encode indexed geometry as a deterministic, self-contained GLB 2.0 scene. */
|
|
||||||
export function encodeGeometryGlb({positions,normals,texcoords,indices}) {
|
|
||||||
if([...positions,...normals,...texcoords].some(value=>!Number.isFinite(value))||indices.some(value=>!Number.isInteger(value)||value<0))throw new TypeError("Invalid demo geometry");
|
|
||||||
const streams=[new Float32Array(positions),new Float32Array(normals),new Float32Array(texcoords),new Uint32Array(indices)];
|
|
||||||
if(!streams[0].length||streams[0].length%3||streams[1].length!==streams[0].length||streams[2].length/2!==streams[0].length/3||streams[3].length%3) throw new TypeError("Invalid demo geometry");
|
|
||||||
const offsets=[], chunks=[], views=[]; let byteLength=0;
|
|
||||||
for(const stream of streams){byteLength=align4(byteLength);offsets.push(byteLength);const bytes=new Uint8Array(stream.buffer);chunks.push({offset:byteLength,bytes});views.push({buffer:0,byteOffset:byteLength,byteLength:bytes.length});byteLength+=bytes.length;}
|
|
||||||
byteLength=align4(byteLength);
|
|
||||||
const vertexCount=streams[0].length/3, bounds=finiteMinMax(streams[0],3);
|
|
||||||
if(indices.some(value=>value>=vertexCount))throw new TypeError("Invalid demo geometry");
|
|
||||||
const nodes=[]; for(let z=-1;z<=1;z++)for(let x=-1;x<=1;x++)nodes.push({mesh:0,translation:[x*3,0,z*3]});
|
|
||||||
const json={asset:{version:"2.0",generator:"yawn-demo"},scene:0,scenes:[{nodes:nodes.map((_,i)=>i)}],nodes,meshes:[{primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3}]}],buffers:[{byteLength}],bufferViews:views,accessors:[
|
|
||||||
{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},
|
|
||||||
{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},
|
|
||||||
{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},
|
|
||||||
{bufferView:3,componentType:5125,count:streams[3].length,type:"SCALAR"},
|
|
||||||
]};
|
|
||||||
let jsonBytes=encoder.encode(JSON.stringify(json)); const jsonLength=align4(jsonBytes.length), total=12+8+jsonLength+8+byteLength;
|
|
||||||
const out=new ArrayBuffer(total), view=new DataView(out), bytes=new Uint8Array(out); view.setUint32(0,0x46546c67,true);view.setUint32(4,2,true);view.setUint32(8,total,true);
|
|
||||||
view.setUint32(12,jsonLength,true);view.setUint32(16,JSON_CHUNK,true);bytes.fill(0x20,20,20+jsonLength);bytes.set(jsonBytes,20);
|
|
||||||
const binHeader=20+jsonLength;view.setUint32(binHeader,byteLength,true);view.setUint32(binHeader+4,BIN_CHUNK,true);for(const chunk of chunks)bytes.set(chunk.bytes,binHeader+8+chunk.offset);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCubeGeometry(){
|
|
||||||
const positions=[],normals=[],texcoords=[],indices=[];const faces=[[[1,0,0],[1,-1,-1],[1,-1,1],[1,1,1],[1,1,-1]],[[-1,0,0],[-1,-1,1],[-1,-1,-1],[-1,1,-1],[-1,1,1]],[[0,1,0],[-1,1,1],[1,1,1],[1,1,-1],[-1,1,-1]],[[0,-1,0],[-1,-1,-1],[1,-1,-1],[1,-1,1],[-1,-1,1]],[[0,0,1],[-1,-1,1],[1,-1,1],[1,1,1],[-1,1,1]],[[0,0,-1],[1,-1,-1],[-1,-1,-1],[-1,1,-1],[1,1,-1]]];
|
|
||||||
for(const [normal,...corners] of faces){
|
|
||||||
const base=positions.length/3;corners.forEach((p,i)=>{positions.push(...p);normals.push(...normal);texcoords.push(...[[0,0],[1,0],[1,1],[0,1]][i]);});
|
|
||||||
const a=corners[0],b=corners[1],c=corners[2],ab=b.map((value,i)=>value-a[i]),ac=c.map((value,i)=>value-a[i]);
|
|
||||||
const cross=[ab[1]*ac[2]-ab[2]*ac[1],ab[2]*ac[0]-ab[0]*ac[2],ab[0]*ac[1]-ab[1]*ac[0]];
|
|
||||||
const outward=cross.reduce((sum,value,i)=>sum+value*normal[i],0)>0;
|
|
||||||
indices.push(...(outward?[base,base+1,base+2,base,base+2,base+3]:[base,base+2,base+1,base,base+3,base+2]));
|
|
||||||
}
|
|
||||||
return {positions,normals,texcoords,indices};
|
|
||||||
}
|
|
||||||
export function createUvSphereGeometry(segments=24,rings=12){
|
|
||||||
const positions=[],normals=[],texcoords=[],indices=[];for(let y=0;y<=rings;y++){const v=y/rings,phi=v*Math.PI;for(let x=0;x<=segments;x++){const u=x/segments,theta=u*Math.PI*2,nx=Math.sin(phi)*Math.cos(theta),ny=Math.cos(phi),nz=Math.sin(phi)*Math.sin(theta);positions.push(nx,ny,nz);normals.push(nx,ny,nz);texcoords.push(u,v);}}
|
|
||||||
for(let y=0;y<rings;y++)for(let x=0;x<segments;x++){const a=y*(segments+1)+x,b=a+segments+1;indices.push(a,a+1,b,a+1,b+1,b);}return {positions,normals,texcoords,indices};
|
|
||||||
}
|
|
||||||
|
|
||||||
const galleryPngBase64=Object.freeze({
|
|
||||||
base:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAIUlEQVR42mP4ryH3X+NOgIZGwP//DP/vyGn8BwI5Obn/AKsPDa3HqsdFAAAAAElFTkSuQmCC",
|
|
||||||
mr:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAJUlEQVR42gEaAOX/AP8gAP//YED//7Sg/wD/8P///0Dc///cIP/dYBIQ76JUtAAAAABJRU5ErkJggg==",
|
|
||||||
normal:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAH0lEQVR42mNoaPj//0TDu/8WQMzQcOLd/wYLIAYKAgDieBGxoS0BjwAAAABJRU5ErkJggg==",
|
|
||||||
});
|
|
||||||
const decodeBase64=value=>{const binary=atob(value),bytes=new Uint8Array(binary.length);for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);return bytes;};
|
|
||||||
|
|
||||||
/** Build a deterministic PBR shader validation gallery. */
|
|
||||||
export function createMaterialGalleryGlb(){
|
|
||||||
// A modest shared sphere keeps the embedded GLB compact while making roughness
|
|
||||||
// and normal-map responses much easier to compare than the former cubes.
|
|
||||||
const geometry=createUvSphereGeometry(16,8);
|
|
||||||
const streams=[new Float32Array(geometry.positions),new Float32Array(geometry.normals),new Float32Array(geometry.texcoords),new Uint32Array(geometry.indices)];
|
|
||||||
const images=Object.values(galleryPngBase64).map(decodeBase64),chunks=[],bufferViews=[];let byteLength=0;
|
|
||||||
for(const stream of [...streams,...images]){byteLength=align4(byteLength);const bytes=stream instanceof Uint8Array?stream:new Uint8Array(stream.buffer);chunks.push({offset:byteLength,bytes});bufferViews.push({buffer:0,byteOffset:byteLength,byteLength:bytes.length});byteLength+=bytes.length;}
|
|
||||||
byteLength=align4(byteLength);const bounds=finiteMinMax(streams[0],3),vertexCount=geometry.positions.length/3;
|
|
||||||
const materials=[
|
|
||||||
...[0.08,0.3,0.6,1].map(roughnessFactor=>({name:`Dielectric roughness ${roughnessFactor}`,pbrMetallicRoughness:{baseColorFactor:[0.72,0.18,0.08,1],metallicFactor:0,roughnessFactor}})),
|
|
||||||
...[0.08,0.3,0.6,1].map(roughnessFactor=>({name:`Metal roughness ${roughnessFactor}`,pbrMetallicRoughness:{baseColorFactor:[0.72,0.76,0.82,1],metallicFactor:1,roughnessFactor}})),
|
|
||||||
...[1,1.5,2].map(ior=>({name:`Dielectric IOR ${ior}`,pbrMetallicRoughness:{baseColorFactor:[0.12,0.48,0.82,1],metallicFactor:0,roughnessFactor:0.18},extensions:{KHR_materials_ior:{ior}}})),
|
|
||||||
{name:"Odd-width OpenGL normal map",pbrMetallicRoughness:{baseColorFactor:[0.7,0.7,0.7,1],metallicFactor:0,roughnessFactor:0.4},normalTexture:{index:2,scale:1}},
|
|
||||||
{name:"Odd-width AO",pbrMetallicRoughness:{baseColorFactor:[0.8,0.55,0.12,1],metallicFactor:0,roughnessFactor:0.65},occlusionTexture:{index:1,strength:1}},
|
|
||||||
{name:"Odd-width emissive",pbrMetallicRoughness:{baseColorFactor:[0.03,0.03,0.03,1],metallicFactor:0,roughnessFactor:0.8},emissiveFactor:[1,0.3,0.05],emissiveTexture:{index:0}},
|
|
||||||
{name:"Odd-width alpha MASK",pbrMetallicRoughness:{baseColorFactor:[1,1,1,1],baseColorTexture:{index:0},metallicFactor:0,roughnessFactor:0.55},alphaMode:"MASK",alphaCutoff:0.5,doubleSided:true},
|
|
||||||
{name:"Reflected non-uniform double-sided",pbrMetallicRoughness:{baseColorFactor:[0.25,0.85,0.38,1],metallicFactor:0.15,roughnessFactor:0.45},doubleSided:true},
|
|
||||||
];
|
|
||||||
const nodes=materials.map((material,index)=>({name:material.name,mesh:index,translation:[(index%4-1.5)*2.5,(1.5-Math.floor(index/4))*2.5,0],...(index===15?{scale:[-1.25,0.7,1.1]}:{})}));
|
|
||||||
const meshes=materials.map((material,index)=>({name:material.name,primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3,material:index}]}));
|
|
||||||
const json={asset:{version:"2.0",generator:"yawn-pbr-gallery"},extensionsUsed:["KHR_materials_ior"],scene:0,scenes:[{name:"Deterministic PBR gallery",nodes:nodes.map((_,i)=>i)}],nodes,meshes,materials,
|
|
||||||
samplers:[{magFilter:9728,minFilter:9728,wrapS:10497,wrapT:10497}],images:images.map((_,i)=>({name:["Odd-width sRGB base color and emissive","Odd-width linear MR and AO","Odd-width OpenGL normal map"][i],bufferView:i+4,mimeType:"image/png"})),textures:images.map((_,i)=>({sampler:0,source:i})),
|
|
||||||
buffers:[{byteLength}],bufferViews,accessors:[{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},{bufferView:3,componentType:5125,count:geometry.indices.length,type:"SCALAR"}]};
|
|
||||||
let jsonBytes=encoder.encode(JSON.stringify(json));const jsonLength=align4(jsonBytes.length),total=12+8+jsonLength+8+byteLength,out=new ArrayBuffer(total),view=new DataView(out),bytes=new Uint8Array(out);
|
|
||||||
view.setUint32(0,0x46546c67,true);view.setUint32(4,2,true);view.setUint32(8,total,true);view.setUint32(12,jsonLength,true);view.setUint32(16,JSON_CHUNK,true);bytes.fill(0x20,20,20+jsonLength);bytes.set(jsonBytes,20);const binHeader=20+jsonLength;view.setUint32(binHeader,byteLength,true);view.setUint32(binHeader+4,BIN_CHUNK,true);for(const chunk of chunks)bytes.set(chunk.bytes,binHeader+8+chunk.offset);return out;
|
|
||||||
}
|
|
||||||
export const loadouts=Object.freeze({cubes:{label:"Procedural cubes"},spheres:{label:"Procedural spheres"},materials:{label:"PBR material gallery"}});
|
|
||||||
export async function loadDemoLoadout(id){
|
|
||||||
if(id==="cubes")return encodeGeometryGlb(createCubeGeometry());
|
|
||||||
if(id==="spheres")return encodeGeometryGlb(createUvSphereGeometry());
|
|
||||||
if(id==="materials")return createMaterialGalleryGlb();
|
|
||||||
throw new RangeError(`Unknown loadout: ${id}`);
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<title>Yawn Package Integration Example</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #0b0e14;
|
|
||||||
color: #eef2f8;
|
|
||||||
font:
|
|
||||||
14px Inter,
|
|
||||||
system-ui,
|
|
||||||
sans-serif;
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 3fr) minmax(380px, 2fr);
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.viewport,
|
|
||||||
.editor {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
canvas {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
#canvas0 {
|
|
||||||
background: #090d16;
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 2;
|
|
||||||
inset: 18px 18px auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: end;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 13px 16px;
|
|
||||||
border: 1px solid #ffffff18;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #111722e8;
|
|
||||||
box-shadow: 0 12px 30px #0008;
|
|
||||||
}
|
|
||||||
.brand {
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
.brand strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 17px;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
.brand small {
|
|
||||||
color: #8d9bb1;
|
|
||||||
}
|
|
||||||
.field {
|
|
||||||
display: grid;
|
|
||||||
gap: 5px;
|
|
||||||
color: #9da9ba;
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
select,
|
|
||||||
button {
|
|
||||||
font: inherit;
|
|
||||||
color: #eef;
|
|
||||||
background: #202938;
|
|
||||||
border: 1px solid #3a4659;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 7px 10px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background: #ba5c2e;
|
|
||||||
border-color: #d77949;
|
|
||||||
font-weight: 650;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
button:disabled,
|
|
||||||
select:disabled {
|
|
||||||
opacity: 0.48;
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
#demo-status {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 2;
|
|
||||||
left: 18px;
|
|
||||||
bottom: 18px;
|
|
||||||
padding: 9px 12px;
|
|
||||||
border-radius: 7px;
|
|
||||||
background: #0b1019dc;
|
|
||||||
color: #bac6d8;
|
|
||||||
box-shadow: 0 5px 20px #0008;
|
|
||||||
}
|
|
||||||
.editor {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 58px minmax(0, 1fr);
|
|
||||||
border-left: 1px solid #2d3542;
|
|
||||||
background: #151820;
|
|
||||||
}
|
|
||||||
.editor-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border-bottom: 1px solid #303745;
|
|
||||||
}
|
|
||||||
.editor-bar strong {
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
.editor-bar span {
|
|
||||||
color: #9ba7b9;
|
|
||||||
}
|
|
||||||
.fxnode-add-menu {
|
|
||||||
position: fixed; z-index: 20; width: min(280px, calc(100vw - 16px)); max-height: min(440px, calc(100vh - 16px));
|
|
||||||
padding: 8px; overflow: hidden; border: 1px solid #495568; border-radius: 8px; background: #171d27; box-shadow: 0 14px 40px #000b;
|
|
||||||
}
|
|
||||||
.fxnode-add-menu[hidden] { display: none; }
|
|
||||||
.fxnode-add-menu input { width: 100%; padding: 8px; color: #eef2f8; background: #0f141c; border: 1px solid #3a4659; border-radius: 5px; }
|
|
||||||
.fxnode-add-menu__list { max-height: 360px; margin-top: 6px; overflow: auto; }
|
|
||||||
.fxnode-add-menu__group { padding: 8px 7px 3px; color: #8491a5; font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
|
||||||
.fxnode-add-menu .fxnode-add-menu__option { display: block; width: 100%; padding: 7px 9px; border: 0; text-align: left; text-transform: capitalize; background: transparent; }
|
|
||||||
.fxnode-add-menu__option[aria-selected="true"] { background: #394a64; outline: 1px solid #6883aa; }
|
|
||||||
@media (max-width: 820px) {
|
|
||||||
main {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: 52% 48%;
|
|
||||||
}
|
|
||||||
.editor {
|
|
||||||
border-left: 0;
|
|
||||||
border-top: 1px solid #2d3542;
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.brand {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<section class="viewport" aria-label="Rendered scene">
|
|
||||||
<div class="toolbar">
|
|
||||||
<div class="brand">
|
|
||||||
<strong>YAWN</strong><small>Render Graph Studio</small>
|
|
||||||
</div>
|
|
||||||
<label class="field" for="loadout-select"
|
|
||||||
>Scene loadout<select id="loadout-select">
|
|
||||||
<option value="cubes">Cubes</option>
|
|
||||||
<option value="spheres">UV spheres</option>
|
|
||||||
<option value="materials">PBR material gallery</option>
|
|
||||||
</select></label
|
|
||||||
><label class="field" for="graph-select"
|
|
||||||
>Graph preset<select id="graph-select">
|
|
||||||
<option value="authored">Authored</option>
|
|
||||||
<option value="jso">JSO addon</option>
|
|
||||||
</select></label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<canvas id="canvas0"></canvas
|
|
||||||
><output id="demo-status" aria-live="polite">Starting renderer…</output>
|
|
||||||
</section>
|
|
||||||
<section class="editor" aria-label="Render graph editor">
|
|
||||||
<div class="editor-bar">
|
|
||||||
<strong>Authored Graph</strong
|
|
||||||
><button id="apply-graph" disabled>Apply</button
|
|
||||||
><span id="graph-status">Loading editor…</span>
|
|
||||||
</div>
|
|
||||||
<canvas id="graph-editor"></canvas>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<script type="module" src="./index.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
import { YawnCore, RendererError } from "@yawn/core";
|
|
||||||
import { MeshHandles } from "@yawn/mesh-handles";
|
|
||||||
import { installCameraRenderDataControls } from "../shared/camera-controls.js";
|
|
||||||
import { createWorkerTransport } from "../shared/create-worker-transport.js";
|
|
||||||
import { loadDemoLoadout } from "./demo-loadouts.js";
|
|
||||||
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
|
|
||||||
import { createGraphAst } from "@yawn/render-graph-ast";
|
|
||||||
import { loadGraph } from "@yawn/render-graph-js";
|
|
||||||
import { GltfImporter } from "@yawn/gltf-import";
|
|
||||||
import { defaultPipelines } from "@yawn/default-pipelines";
|
|
||||||
import { AuthoringController } from "./render-graph/authoring-controller.js";
|
|
||||||
import { createRenderGraphEditor } from "./render-graph/fxnode-editor.js";
|
|
||||||
import { renderGraphPresets } from "./render-graph/presets.js";
|
|
||||||
|
|
||||||
let renderer,
|
|
||||||
meshHandles,
|
|
||||||
gltfImporter,
|
|
||||||
editor,
|
|
||||||
controller,
|
|
||||||
assetAbort,
|
|
||||||
busy = false,
|
|
||||||
cleaned = false;
|
|
||||||
let unsubscribeController = () => {},
|
|
||||||
unsubscribeSnapshots = () => {};
|
|
||||||
const listeners = [];
|
|
||||||
const on = (target, type, fn) => {
|
|
||||||
target.addEventListener(type, fn);
|
|
||||||
listeners.push(() => target.removeEventListener(type, fn));
|
|
||||||
};
|
|
||||||
const status = (message) => {
|
|
||||||
const node = document.querySelector("#demo-status");
|
|
||||||
if (node) node.textContent = message;
|
|
||||||
};
|
|
||||||
const sameId = (a, b) =>
|
|
||||||
Array.isArray(a) && Array.isArray(b) && a[0] === b[0] && a[1] === b[1];
|
|
||||||
const state = {
|
|
||||||
loadout: "cubes",
|
|
||||||
graph: "authored",
|
|
||||||
compiled: {},
|
|
||||||
telemetry: null,
|
|
||||||
};
|
|
||||||
function publish(telemetry) {
|
|
||||||
state.telemetry = telemetry;
|
|
||||||
document.documentElement.dataset.yawnState = JSON.stringify({
|
|
||||||
activeLoadout: state.loadout,
|
|
||||||
activeGraph: state.graph,
|
|
||||||
renderDataRevision: telemetry.revision,
|
|
||||||
renderMode: telemetry.renderMode,
|
|
||||||
activeCompiledId: telemetry.activeCompiledId,
|
|
||||||
activeCompiledGraph: telemetry.activeCompiledGraph,
|
|
||||||
activeCompiledRevision: telemetry.activeCompiledRevision,
|
|
||||||
activeCompiledSchemaVersion: telemetry.activeCompiledSchemaVersion,
|
|
||||||
graphExecutions: telemetry.graphExecutions,
|
|
||||||
graphTextureSlots: telemetry.graphTextureSlots,
|
|
||||||
draws: telemetry.draws,
|
|
||||||
instances: telemetry.instances,
|
|
||||||
indices: telemetry.indices,
|
|
||||||
gpuError: telemetry.gpuError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function waitTelemetry(predicate, timeout = 30000) {
|
|
||||||
const current = renderer?.telemetry;
|
|
||||||
if (current && predicate(current)) return Promise.resolve(current);
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let timer;
|
|
||||||
const done = () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
renderer.removeEventListener("renderer-frame", frame);
|
|
||||||
};
|
|
||||||
const frame = (e) => {
|
|
||||||
if (predicate(e.detail)) {
|
|
||||||
done();
|
|
||||||
resolve(e.detail);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
done();
|
|
||||||
reject(new Error("Telemetry confirmation timed out"));
|
|
||||||
}, timeout);
|
|
||||||
onAbort = () => {
|
|
||||||
done();
|
|
||||||
reject(new RendererError("DISPOSED"));
|
|
||||||
};
|
|
||||||
renderer.addEventListener("renderer-frame", frame);
|
|
||||||
timer.unref?.();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let onAbort = () => {};
|
|
||||||
async function transaction(label, operation, rollback) {
|
|
||||||
if (busy || cleaned) return false;
|
|
||||||
busy = true;
|
|
||||||
document
|
|
||||||
.querySelectorAll("select, #apply-graph")
|
|
||||||
.forEach((x) => (x.disabled = true));
|
|
||||||
status(label);
|
|
||||||
try {
|
|
||||||
const telemetry = await operation();
|
|
||||||
if (cleaned) return false;
|
|
||||||
if (telemetry) {
|
|
||||||
publish(telemetry);
|
|
||||||
status(
|
|
||||||
`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`,
|
|
||||||
);
|
|
||||||
} else
|
|
||||||
status(
|
|
||||||
`${state.loadout} · ${state.graph} · committed; telemetry pending`,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (!cleaned) {
|
|
||||||
try {
|
|
||||||
await rollback?.();
|
|
||||||
} catch (rollbackError) {
|
|
||||||
console.error("Render graph rollback failed", rollbackError);
|
|
||||||
}
|
|
||||||
console.error("Render graph transaction failed", error);
|
|
||||||
status(`Failed · ${error?.code ?? error?.message ?? error}`);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
if (!cleaned) {
|
|
||||||
document.querySelectorAll("select").forEach((x) => (x.disabled = false));
|
|
||||||
const button = document.querySelector("#apply-graph");
|
|
||||||
if (button) button.disabled = !controller?.canApply;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function selectLoadout(next, select) {
|
|
||||||
const previous = state.loadout,
|
|
||||||
targetRevision = (renderer.telemetry?.revision ?? 0) + 1;
|
|
||||||
assetAbort = new AbortController();
|
|
||||||
const ok = await transaction(`Loading ${next}…`, async () => {
|
|
||||||
const glb = await loadDemoLoadout(next, { signal: assetAbort.signal });
|
|
||||||
const url = URL.createObjectURL(new Blob([glb], { type: "model/gltf-binary" }));
|
|
||||||
try {
|
|
||||||
meshHandles.fromImportedScene(await gltfImporter.load(url));
|
|
||||||
} finally {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
state.loadout = next;
|
|
||||||
return waitTelemetry(
|
|
||||||
(x) =>
|
|
||||||
x.revision === targetRevision &&
|
|
||||||
x.draws > 0 &&
|
|
||||||
x.activeCompiledGraph === state.compiled[state.graph].graphId &&
|
|
||||||
x.gpuError === false,
|
|
||||||
).catch(() => null);
|
|
||||||
});
|
|
||||||
assetAbort = undefined;
|
|
||||||
if (!ok) select.value = previous;
|
|
||||||
}
|
|
||||||
async function selectGraph(next, select) {
|
|
||||||
const previous = state.graph,
|
|
||||||
compiled = state.compiled[next];
|
|
||||||
const ok = await transaction(
|
|
||||||
`Activating ${next}…`,
|
|
||||||
async () => {
|
|
||||||
await renderer.switchCompiledGraph(compiled.compiledId);
|
|
||||||
state.graph = next;
|
|
||||||
return waitTelemetry(
|
|
||||||
(x) =>
|
|
||||||
sameId(x.activeCompiledId, compiled.compiledId) &&
|
|
||||||
x.activeCompiledGraph === compiled.graphId &&
|
|
||||||
x.activeCompiledRevision === compiled.revision &&
|
|
||||||
x.gpuError === false,
|
|
||||||
).catch(() => null);
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
state.graph = previous;
|
|
||||||
select.value = previous;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!ok) select.value = previous;
|
|
||||||
}
|
|
||||||
async function cleanup() {
|
|
||||||
if (cleaned) return;
|
|
||||||
cleaned = true;
|
|
||||||
removeEventListener("pagehide", pagehide);
|
|
||||||
assetAbort?.abort();
|
|
||||||
onAbort();
|
|
||||||
listeners.splice(0).forEach((fn) => fn());
|
|
||||||
unsubscribeController();
|
|
||||||
unsubscribeSnapshots();
|
|
||||||
try {
|
|
||||||
await controller?.destroy();
|
|
||||||
await editor?.destroy();
|
|
||||||
} finally {
|
|
||||||
gltfImporter?.dispose();
|
|
||||||
meshHandles?.dispose();
|
|
||||||
renderer?.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const pagehide = () => {
|
|
||||||
void cleanup();
|
|
||||||
};
|
|
||||||
|
|
||||||
async function start() {
|
|
||||||
addEventListener("pagehide", pagehide, { once: true });
|
|
||||||
delete document.documentElement.dataset.yawnReady;
|
|
||||||
const transport = createWorkerTransport(document.querySelector("#canvas0"));
|
|
||||||
renderer = new YawnCore(transport);
|
|
||||||
meshHandles = new MeshHandles(renderer);
|
|
||||||
gltfImporter = new GltfImporter(renderer);
|
|
||||||
await renderer.ready;
|
|
||||||
listeners.push(
|
|
||||||
installCameraRenderDataControls(renderer, document.querySelector("#canvas0")),
|
|
||||||
);
|
|
||||||
const nextEditor = await createRenderGraphEditor(
|
|
||||||
document.querySelector("#graph-editor"),
|
|
||||||
);
|
|
||||||
if (cleaned) {
|
|
||||||
await nextEditor.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor = nextEditor;
|
|
||||||
controller = new AuthoringController({
|
|
||||||
renderer,
|
|
||||||
adapt: (snapshot, revision) =>
|
|
||||||
adaptFxNodeSnapshot(snapshot, revision, { pipelines: defaultPipelines }),
|
|
||||||
});
|
|
||||||
const apply = document.querySelector("#apply-graph"),
|
|
||||||
graphStatus = document.querySelector("#graph-status"),
|
|
||||||
loadoutSelect = document.querySelector("#loadout-select"),
|
|
||||||
graphSelect = document.querySelector("#graph-select");
|
|
||||||
unsubscribeController = controller.subscribe((s) => {
|
|
||||||
apply.disabled = busy || !s.canApply;
|
|
||||||
graphStatus.textContent = s.error
|
|
||||||
? `Invalid · ${s.error.code ?? s.error.message}`
|
|
||||||
: s.applying
|
|
||||||
? "Applying…"
|
|
||||||
: s.dirty
|
|
||||||
? s.staged
|
|
||||||
? "Ready to apply"
|
|
||||||
: "Validating…"
|
|
||||||
: `Authored revision ${s.revision}`;
|
|
||||||
});
|
|
||||||
unsubscribeSnapshots = editor.onSnapshots((snapshot) =>
|
|
||||||
controller.markDirty(snapshot),
|
|
||||||
);
|
|
||||||
controller.markDirty(await editor.getState());
|
|
||||||
const authored = await controller.apply();
|
|
||||||
state.compiled.authored = { ...authored, graphId: "authored_gpu_culling" };
|
|
||||||
for (const [name, preset] of Object.entries(renderGraphPresets)) {
|
|
||||||
// The explicit AST construction shows the common boundary shared by JSO and FXNode.
|
|
||||||
const compiled = await loadGraph(renderer, createGraphAst(preset));
|
|
||||||
state.compiled[name] = {
|
|
||||||
...compiled,
|
|
||||||
graphId: preset.id,
|
|
||||||
revision: preset.revision,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
on(renderer, "renderer-frame", (event) => {
|
|
||||||
const expected = state.compiled[state.graph],
|
|
||||||
telemetry = event.detail;
|
|
||||||
if (
|
|
||||||
expected &&
|
|
||||||
telemetry.activeCompiledGraph === expected.graphId &&
|
|
||||||
sameId(telemetry.activeCompiledId, expected.compiledId) &&
|
|
||||||
telemetry.gpuError === false
|
|
||||||
) {
|
|
||||||
publish(telemetry);
|
|
||||||
if (!busy)
|
|
||||||
status(
|
|
||||||
`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
on(
|
|
||||||
loadoutSelect,
|
|
||||||
"change",
|
|
||||||
() => void selectLoadout(loadoutSelect.value, loadoutSelect),
|
|
||||||
);
|
|
||||||
on(
|
|
||||||
graphSelect,
|
|
||||||
"change",
|
|
||||||
() => void selectGraph(graphSelect.value, graphSelect),
|
|
||||||
);
|
|
||||||
on(apply, "click", () => {
|
|
||||||
const previous = state.graph,
|
|
||||||
previousAuthored = state.compiled.authored;
|
|
||||||
void transaction(
|
|
||||||
"Applying authored graph…",
|
|
||||||
async () => {
|
|
||||||
const compiled = await controller.apply();
|
|
||||||
state.compiled.authored = {
|
|
||||||
...compiled,
|
|
||||||
graphId: "authored_gpu_culling",
|
|
||||||
};
|
|
||||||
state.graph = "authored";
|
|
||||||
graphSelect.value = "authored";
|
|
||||||
return waitTelemetry(
|
|
||||||
(x) =>
|
|
||||||
sameId(x.activeCompiledId, compiled.compiledId) &&
|
|
||||||
x.activeCompiledGraph === "authored_gpu_culling" &&
|
|
||||||
x.activeCompiledRevision === compiled.revision &&
|
|
||||||
x.gpuError === false,
|
|
||||||
).catch(() => null);
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
state.compiled.authored = previousAuthored;
|
|
||||||
state.graph = previous;
|
|
||||||
graphSelect.value = previous;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await editor.whenRendered();
|
|
||||||
const initialized = await transaction(
|
|
||||||
"Preparing procedural cubes…",
|
|
||||||
async () => {
|
|
||||||
const targetRevision = (renderer.telemetry?.revision ?? 0) + 1;
|
|
||||||
const glb = await loadDemoLoadout("cubes");
|
|
||||||
const url = URL.createObjectURL(new Blob([glb], { type: "model/gltf-binary" }));
|
|
||||||
try {
|
|
||||||
meshHandles.fromImportedScene(await gltfImporter.load(url));
|
|
||||||
} finally {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
await renderer.switchCompiledGraph(authored.compiledId);
|
|
||||||
return waitTelemetry(
|
|
||||||
(x) =>
|
|
||||||
x.revision === targetRevision &&
|
|
||||||
x.draws > 0 &&
|
|
||||||
x.activeCompiledGraph === "authored_gpu_culling" &&
|
|
||||||
x.activeCompiledRevision === authored.revision &&
|
|
||||||
x.gpuError === false,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!initialized) throw new Error("Initial demo transaction failed");
|
|
||||||
document.documentElement.dataset.yawnReady = "true";
|
|
||||||
}
|
|
||||||
const startupError = (error) => {
|
|
||||||
if (cleaned) return;
|
|
||||||
console.error("Render graph startup failed", error);
|
|
||||||
status(`Startup failed · ${error?.code ?? error}`);
|
|
||||||
void cleanup();
|
|
||||||
};
|
|
||||||
if (document.readyState === "loading")
|
|
||||||
document.addEventListener(
|
|
||||||
"DOMContentLoaded",
|
|
||||||
() => start().catch(startupError),
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
else start().catch(startupError);
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { NODE_TITLE_OVERRIDES, semanticCatalog } from "@yawn/render-graph-fxnode/catalog";
|
|
||||||
|
|
||||||
// Example-only DOM menu for the FXNode frontend.
|
|
||||||
|
|
||||||
const GROUPS = Object.freeze([
|
|
||||||
["source", "Source"],
|
|
||||||
["expression", "Expression"],
|
|
||||||
["compute", "Compute"],
|
|
||||||
["cpu_preparation", "CPU preparation"],
|
|
||||||
["render", "Render / post"],
|
|
||||||
["frame", "Frame"],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const title = (typeId) => typeId.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
||||||
|
|
||||||
/** Application-owned, immutable add-node catalog model. */
|
|
||||||
export const addNodeItems = Object.freeze(
|
|
||||||
GROUPS.flatMap(([execution, group]) =>
|
|
||||||
Object.entries(semanticCatalog)
|
|
||||||
.filter(([, definition]) => definition.execution === execution)
|
|
||||||
.map(([typeId]) => Object.freeze({ typeId, title: NODE_TITLE_OVERRIDES[typeId] ?? title(typeId), group })),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export function searchAddNodeItems(query, items = addNodeItems) {
|
|
||||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
|
||||||
return items.filter((item) => terms.every((term) =>
|
|
||||||
`${item.title} ${item.typeId} ${item.group}`.toLocaleLowerCase().includes(term),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveAddNodeSelection(index, delta, length) {
|
|
||||||
return length ? ((Math.max(0, index) + delta) % length + length) % length : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Creates one transient DOM menu owned by the application rather than fxnode. */
|
|
||||||
export function createAddNodeMenu(ownerDocument = document) {
|
|
||||||
const ownerWindow = ownerDocument.defaultView;
|
|
||||||
const root = ownerDocument.createElement("div");
|
|
||||||
root.className = "fxnode-add-menu";
|
|
||||||
root.hidden = true;
|
|
||||||
root.setAttribute("role", "dialog");
|
|
||||||
root.setAttribute("aria-label", "Add render graph node");
|
|
||||||
const input = ownerDocument.createElement("input");
|
|
||||||
input.type = "search";
|
|
||||||
input.placeholder = "Search nodes…";
|
|
||||||
input.setAttribute("aria-label", "Search nodes");
|
|
||||||
input.setAttribute("aria-controls", "fxnode-add-options");
|
|
||||||
input.setAttribute("aria-autocomplete", "list");
|
|
||||||
const list = ownerDocument.createElement("div");
|
|
||||||
list.id = "fxnode-add-options";
|
|
||||||
list.className = "fxnode-add-menu__list";
|
|
||||||
list.setAttribute("role", "listbox");
|
|
||||||
root.append(input, list);
|
|
||||||
ownerDocument.body.append(root);
|
|
||||||
let resolve, filtered = addNodeItems, selected = 0, serial = 0, previousFocus;
|
|
||||||
|
|
||||||
const close = (value = null) => {
|
|
||||||
if (root.hidden) return;
|
|
||||||
root.hidden = true;
|
|
||||||
const done = resolve;
|
|
||||||
resolve = undefined;
|
|
||||||
previousFocus?.focus?.();
|
|
||||||
previousFocus = undefined;
|
|
||||||
done?.(value);
|
|
||||||
};
|
|
||||||
const render = () => {
|
|
||||||
filtered = searchAddNodeItems(input.value);
|
|
||||||
selected = filtered.length ? Math.min(Math.max(selected, 0), filtered.length - 1) : -1;
|
|
||||||
list.replaceChildren();
|
|
||||||
let group;
|
|
||||||
for (const [index, item] of filtered.entries()) {
|
|
||||||
if (item.group !== group) {
|
|
||||||
group = item.group;
|
|
||||||
const heading = ownerDocument.createElement("div");
|
|
||||||
heading.className = "fxnode-add-menu__group";
|
|
||||||
heading.textContent = group;
|
|
||||||
heading.setAttribute("role", "presentation");
|
|
||||||
list.append(heading);
|
|
||||||
}
|
|
||||||
const option = ownerDocument.createElement("button");
|
|
||||||
option.type = "button";
|
|
||||||
option.id = `fxnode-add-option-${serial}-${index}`;
|
|
||||||
option.className = "fxnode-add-menu__option";
|
|
||||||
option.dataset.typeId = item.typeId;
|
|
||||||
option.textContent = item.title;
|
|
||||||
option.setAttribute("role", "option");
|
|
||||||
option.setAttribute("aria-selected", String(index === selected));
|
|
||||||
option.tabIndex = -1;
|
|
||||||
option.addEventListener("pointermove", () => { selected = index; render(); });
|
|
||||||
option.addEventListener("click", () => close(item.typeId));
|
|
||||||
list.append(option);
|
|
||||||
}
|
|
||||||
const active = selected >= 0 ? list.querySelector(`[data-type-id="${filtered[selected].typeId}"]`) : null;
|
|
||||||
input.setAttribute("aria-activedescendant", active?.id ?? "");
|
|
||||||
active?.scrollIntoView({ block: "nearest" });
|
|
||||||
};
|
|
||||||
const reposition = () => {
|
|
||||||
if (root.hidden) return;
|
|
||||||
const margin = 8, box = root.getBoundingClientRect();
|
|
||||||
root.style.left = `${Math.max(margin, Math.min(Number(root.dataset.x), ownerWindow.innerWidth - box.width - margin))}px`;
|
|
||||||
root.style.top = `${Math.max(margin, Math.min(Number(root.dataset.y), ownerWindow.innerHeight - box.height - margin))}px`;
|
|
||||||
};
|
|
||||||
input.addEventListener("input", () => { selected = 0; render(); });
|
|
||||||
input.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
||||||
event.preventDefault(); selected = moveAddNodeSelection(selected, event.key === "ArrowDown" ? 1 : -1, filtered.length); render();
|
|
||||||
} else if (event.key === "Enter" && selected >= 0) {
|
|
||||||
event.preventDefault(); close(filtered[selected].typeId);
|
|
||||||
} else if (event.key === "Escape") { event.preventDefault(); close(); }
|
|
||||||
});
|
|
||||||
const outside = (event) => { if (!root.hidden && !root.contains(event.target)) close(); };
|
|
||||||
ownerDocument.addEventListener("pointerdown", outside, true);
|
|
||||||
ownerWindow.addEventListener("resize", close);
|
|
||||||
ownerWindow.addEventListener("blur", close);
|
|
||||||
return {
|
|
||||||
open({ x, y }) {
|
|
||||||
close();
|
|
||||||
serial++;
|
|
||||||
previousFocus = ownerDocument.activeElement;
|
|
||||||
root.dataset.x = String(x); root.dataset.y = String(y);
|
|
||||||
input.value = ""; selected = 0; root.hidden = false; render(); reposition(); input.focus();
|
|
||||||
return new Promise((done) => { resolve = done; });
|
|
||||||
},
|
|
||||||
close,
|
|
||||||
destroy() {
|
|
||||||
close();
|
|
||||||
ownerDocument.removeEventListener("pointerdown", outside, true);
|
|
||||||
ownerWindow.removeEventListener("resize", close);
|
|
||||||
ownerWindow.removeEventListener("blur", close);
|
|
||||||
root.remove();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { mapAuthoringDiagnostic } from "@yawn/render-graph-fxnode";
|
|
||||||
import { loadGraph } from "@yawn/render-graph-js";
|
|
||||||
|
|
||||||
// Example lifecycle glue; package frontends remain independent of this controller.
|
|
||||||
|
|
||||||
export class AuthoringController {
|
|
||||||
#renderer; #adapt; #revision = 0; #nextRevision = 1; #generation = 0;
|
|
||||||
#current; #lastGood; #applyPromise; #listeners = new Set();
|
|
||||||
#owned = new Map(); #drops = new Map(); #activeCompiles = new Set();
|
|
||||||
#disposed = false; #applyingRecord; #scheduler; #debounceMs; #timer; #destroyPromise;
|
|
||||||
|
|
||||||
constructor({ renderer, adapt, scheduler = globalThis, debounceMs = 150 }) {
|
|
||||||
this.#renderer = renderer; this.#adapt = adapt;
|
|
||||||
this.#scheduler = scheduler; this.#debounceMs = debounceMs;
|
|
||||||
}
|
|
||||||
get revision() { return this.#revision; }
|
|
||||||
get dirty() { return !!this.#current; }
|
|
||||||
get applying() { return !!this.#applyPromise; }
|
|
||||||
get staged() { return this.#current?.candidate ?? null; }
|
|
||||||
get canApply() { return !this.#disposed && !!this.#current?.candidate && !this.#applyPromise; }
|
|
||||||
subscribe(fn) {
|
|
||||||
if (this.#disposed) return () => {};
|
|
||||||
this.#listeners.add(fn); fn(this.#state());
|
|
||||||
return () => this.#listeners.delete(fn);
|
|
||||||
}
|
|
||||||
#state() { return { revision: this.#revision, dirty: this.dirty, applying: this.applying, staged: this.staged, canApply: this.canApply, error: this.#current?.diagnostic ?? null }; }
|
|
||||||
#emit() { if (!this.#disposed) for (const fn of this.#listeners) fn(this.#state()); }
|
|
||||||
#key(id) { return JSON.stringify(id); }
|
|
||||||
#drop(candidate) {
|
|
||||||
if (!candidate) return Promise.resolve();
|
|
||||||
const key = this.#key(candidate.compiledId);
|
|
||||||
if (!this.#owned.has(key)) return this.#drops.get(key) ?? Promise.resolve();
|
|
||||||
if (this.#drops.has(key)) return this.#drops.get(key);
|
|
||||||
let result;
|
|
||||||
try { result = this.#renderer.dropCompiledGraph(candidate.compiledId); }
|
|
||||||
catch (error) { result = Promise.reject(error); }
|
|
||||||
const dropping = Promise.resolve(result)
|
|
||||||
.then(() => { this.#owned.delete(key); })
|
|
||||||
.finally(() => { this.#drops.delete(key); });
|
|
||||||
this.#drops.set(key, dropping);
|
|
||||||
return dropping;
|
|
||||||
}
|
|
||||||
#retire(candidate) { if (candidate) void this.#drop(candidate).catch(() => {}); }
|
|
||||||
#start(record) {
|
|
||||||
if (!record.compile) {
|
|
||||||
record.compile = this.#compile(record);
|
|
||||||
this.#activeCompiles.add(record.compile);
|
|
||||||
record.compile.finally(() => this.#activeCompiles.delete(record.compile));
|
|
||||||
}
|
|
||||||
return record.compile;
|
|
||||||
}
|
|
||||||
#flush(record = this.#current) {
|
|
||||||
if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; }
|
|
||||||
return record ? this.#start(record) : null;
|
|
||||||
}
|
|
||||||
markDirty(snapshot) {
|
|
||||||
if (this.#disposed) return;
|
|
||||||
const previous = this.#current;
|
|
||||||
const record = { generation: ++this.#generation, snapshot, candidate: null, error: null, diagnostic: null, compile: null };
|
|
||||||
this.#current = record;
|
|
||||||
if (previous?.candidate && previous !== this.#applyingRecord && previous.candidate !== this.#lastGood) this.#retire(previous.candidate);
|
|
||||||
if (this.#timer) this.#scheduler.clearTimeout(this.#timer);
|
|
||||||
this.#timer = this.#scheduler.setTimeout(() => { this.#timer = undefined; if (!this.#disposed) this.#start(record); }, this.#debounceMs);
|
|
||||||
this.#emit();
|
|
||||||
}
|
|
||||||
async #compile(record) {
|
|
||||||
let candidate, ir;
|
|
||||||
try {
|
|
||||||
ir = this.#adapt(record.snapshot, this.#nextRevision++);
|
|
||||||
candidate = await loadGraph(this.#renderer, ir);
|
|
||||||
this.#owned.set(this.#key(candidate.compiledId), candidate);
|
|
||||||
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
|
|
||||||
record.candidate = candidate; record.error = record.diagnostic = null; this.#emit(); return candidate;
|
|
||||||
} catch (error) {
|
|
||||||
if (candidate) this.#retire(candidate);
|
|
||||||
record.error = error;
|
|
||||||
record.diagnostic = ir ? mapAuthoringDiagnostic(ir, error) : error;
|
|
||||||
if (this.#current === record) this.#emit();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
apply() {
|
|
||||||
if (this.#disposed) return Promise.resolve(null);
|
|
||||||
if (this.#applyPromise) return this.#applyPromise;
|
|
||||||
const record = this.#current;
|
|
||||||
if (!record) return Promise.resolve(this.#lastGood);
|
|
||||||
this.#applyingRecord = record; this.#flush(record);
|
|
||||||
this.#applyPromise = this.#applyRecord(record); this.#emit(); return this.#applyPromise;
|
|
||||||
}
|
|
||||||
async #applyRecord(record) {
|
|
||||||
try {
|
|
||||||
const candidate = record.candidate ?? (await record.compile);
|
|
||||||
if (!candidate) throw record.error ?? new Error("Graph compilation failed");
|
|
||||||
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
|
|
||||||
await this.#renderer.switchCompiledGraph(candidate.compiledId);
|
|
||||||
const old = this.#lastGood; this.#lastGood = candidate;
|
|
||||||
this.#revision = candidate.revision ?? this.#revision + 1;
|
|
||||||
if (this.#current === record) this.#current = undefined;
|
|
||||||
if (old && old !== candidate) this.#retire(old);
|
|
||||||
return candidate;
|
|
||||||
} finally {
|
|
||||||
if (this.#current !== record && record.candidate && record.candidate !== this.#lastGood) this.#retire(record.candidate);
|
|
||||||
this.#applyPromise = null; this.#applyingRecord = undefined; this.#emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
destroy() {
|
|
||||||
if (this.#destroyPromise) return this.#destroyPromise;
|
|
||||||
this.#disposed = true;
|
|
||||||
if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; }
|
|
||||||
this.#listeners.clear();
|
|
||||||
this.#destroyPromise = this.#finish();
|
|
||||||
return this.#destroyPromise;
|
|
||||||
}
|
|
||||||
async #finish() {
|
|
||||||
const applying = this.#applyPromise;
|
|
||||||
await Promise.allSettled([...(applying ? [applying] : []), ...this.#activeCompiles, ...this.#drops.values()]);
|
|
||||||
await Promise.allSettled([...this.#owned.values()].map((candidate) => this.#drop(candidate)));
|
|
||||||
this.#current = this.#lastGood = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
// Browser input host used only by the interactive package example.
|
|
||||||
const viewport = (canvas, ownerWindow) => ({
|
|
||||||
width: Math.max(1, canvas.clientWidth),
|
|
||||||
height: Math.max(1, canvas.clientHeight),
|
|
||||||
dpr: Math.min(4, Math.max(1, ownerWindow.devicePixelRatio || 1)),
|
|
||||||
});
|
|
||||||
const sameViewport = (a, b) => a.width === b.width && a.height === b.height && a.dpr === b.dpr;
|
|
||||||
const sizeCanvas = (canvas, value) => {
|
|
||||||
canvas.width = Math.round(value.width * value.dpr);
|
|
||||||
canvas.height = Math.round(value.height * value.dpr);
|
|
||||||
};
|
|
||||||
const mods = e => ({ alt:e.altKey, control:e.ctrlKey, meta:e.metaKey, shift:e.shiftKey });
|
|
||||||
|
|
||||||
export function prepareBrowserHost(canvas, { onError=console.error, requestAddNode }={}) {
|
|
||||||
const ownerDocument=canvas.ownerDocument, ownerWindow=ownerDocument.defaultView ?? window;
|
|
||||||
const originalTabIndex=canvas.getAttribute("tabindex"), originalTouchAction=canvas.style.touchAction;
|
|
||||||
let view, root, dead=false, generation=0, requestEpoch=0, resizing=false, pending, appliedViewport, menuPending=false, menuPoint, unsubscribeHost=()=>{};
|
|
||||||
const rootSubscriptions=[];
|
|
||||||
const invalidateAddNode=()=>{requestEpoch++;menuPending=false;requestAddNode?.close?.()};
|
|
||||||
const captured=new Set();
|
|
||||||
const initialViewport=viewport(canvas,ownerWindow); appliedViewport=initialViewport; sizeCanvas(canvas,initialViewport);
|
|
||||||
canvas.tabIndex=0; canvas.style.touchAction="none";
|
|
||||||
const point=e=>{const r=canvas.getBoundingClientRect();return{x:e.clientX-r.left,y:e.clientY-r.top}};
|
|
||||||
const input=e=>{
|
|
||||||
if(!view)return;
|
|
||||||
if(e instanceof ownerWindow.PointerEvent){
|
|
||||||
const phase=e.type==="pointerdown"?"down":e.type==="pointermove"?"move":e.type==="pointerup"?"up":"cancel";
|
|
||||||
if(phase==="down"){invalidateAddNode();menuPending=e.button===2&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&!e.shiftKey&&(e.buttons&1)===0;menuPoint={x:e.clientX,y:e.clientY};canvas.focus();try{canvas.setPointerCapture(e.pointerId);captured.add(e.pointerId)}catch{}}
|
|
||||||
if((phase==="up"||phase==="cancel")&&captured.delete(e.pointerId))try{if(canvas.hasPointerCapture(e.pointerId))canvas.releasePointerCapture(e.pointerId)}catch{}
|
|
||||||
view.feedInput({kind:"pointer",phase,pointerId:e.pointerId,pointerType:e.pointerType,position:point(e),button:e.button,buttons:e.buttons,modifiers:mods(e)});
|
|
||||||
}else if(e instanceof ownerWindow.WheelEvent){
|
|
||||||
e.preventDefault(); invalidateAddNode();
|
|
||||||
const scale=e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_LINE?16:e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_PAGE?Math.max(1,canvas.clientHeight):1;
|
|
||||||
view.feedInput({kind:"wheel",position:point(e),delta:{x:e.deltaX*scale,y:e.deltaY*scale},modifiers:mods(e)});
|
|
||||||
}else if(e instanceof ownerWindow.KeyboardEvent){invalidateAddNode();view.feedInput({kind:"key",phase:e.type==="keydown"?"down":"up",key:e.key,code:e.code,repeat:e.repeat,modifiers:mods(e)});
|
|
||||||
}else view.feedInput({kind:"focus",phase:e.type==="focus"?"focus":"blur"});
|
|
||||||
};
|
|
||||||
const names=["pointerdown","pointermove","pointerup","pointercancel","wheel","keydown","keyup","focus","blur"];
|
|
||||||
const pump=()=>{
|
|
||||||
if(!view||resizing||!pending||dead)return;
|
|
||||||
const next=pending, currentGeneration=generation;pending=undefined;
|
|
||||||
if(sameViewport(next,appliedViewport)){sizeCanvas(canvas,next);pump();return}
|
|
||||||
resizing=true;
|
|
||||||
Promise.resolve(view.setViewport(next)).then(()=>{if(dead||currentGeneration!==generation)return;appliedViewport=next;sizeCanvas(canvas,next)}).catch(error=>{if(!dead&¤tGeneration===generation)onError(error)}).finally(()=>{if(dead||currentGeneration!==generation)return;resizing=false;pump()});
|
|
||||||
};
|
|
||||||
const resize=()=>{if(dead)return;invalidateAddNode();pending=viewport(canvas,ownerWindow);pump()};
|
|
||||||
const outside=e=>{if(view&&e.button===0&&e.target!==canvas&&!canvas.contains(e.target)&&view.getHostSnapshot().colorPickerOpen)view.feedInput({kind:"outside-pointer",button:0})};
|
|
||||||
const lost=e=>captured.delete(e.pointerId);
|
|
||||||
const observer=new ownerWindow.ResizeObserver(resize);
|
|
||||||
return {initialViewport,attach(_root,next){
|
|
||||||
root=_root;view=next;
|
|
||||||
for(const n of names)canvas.addEventListener(n,input,{passive:n!=="wheel"});
|
|
||||||
canvas.addEventListener("contextmenu",prevent);canvas.addEventListener("lostpointercapture",lost);
|
|
||||||
ownerDocument.addEventListener("pointerdown",outside,true);ownerWindow.addEventListener("resize",resize);
|
|
||||||
unsubscribeHost=view.onHostRequests(request=>{if(request.kind!=="add-node-menu"||!menuPending||request.compositionRevision!==view.getHostSnapshot().compositionRevision){invalidateAddNode();return}menuPending=false;const epoch=requestEpoch;requestAddNode?.(request,menuPoint,()=>!dead&&epoch===requestEpoch);});
|
|
||||||
rootSubscriptions.push(root.onMutations(invalidateAddNode),root.onCompositionChanges(invalidateAddNode));
|
|
||||||
observer.observe(canvas);resize();
|
|
||||||
},destroy(){
|
|
||||||
if(dead)return;dead=true;generation++;pending=undefined;invalidateAddNode();observer.disconnect();unsubscribeHost();for(const unsubscribe of rootSubscriptions)unsubscribe();rootSubscriptions.length=0;ownerWindow.removeEventListener("resize",resize);ownerDocument.removeEventListener("pointerdown",outside,true);
|
|
||||||
for(const n of names)canvas.removeEventListener(n,input);canvas.removeEventListener("contextmenu",prevent);canvas.removeEventListener("lostpointercapture",lost);
|
|
||||||
for(const id of captured)try{if(canvas.hasPointerCapture(id))canvas.releasePointerCapture(id)}catch{}captured.clear();
|
|
||||||
if(originalTabIndex===null)canvas.removeAttribute("tabindex");else canvas.setAttribute("tabindex",originalTabIndex);canvas.style.touchAction=originalTouchAction;view=null;root=null;
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
function prevent(e){e.preventDefault()}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { createFxNode } from "@fxnode/index.ts";
|
|
||||||
import { CATALOG_VERSION, GRAPH_ID, fxNodeComposition } from "@yawn/render-graph-fxnode/catalog";
|
|
||||||
import { prepareBrowserHost } from "./browser-host.js";
|
|
||||||
import { createAddNodeMenu } from "./add-node-menu.js";
|
|
||||||
import { createNodeIdAllocator, spawnRequestedNode } from "./node-spawn.js";
|
|
||||||
import { culling } from "./presets.js";
|
|
||||||
|
|
||||||
// Seeds a user-facing FXNode document before exporting it through the addon.
|
|
||||||
|
|
||||||
async function seed(root) {
|
|
||||||
await root.setState({
|
|
||||||
graphId: GRAPH_ID,
|
|
||||||
catalogVersion: CATALOG_VERSION,
|
|
||||||
nodes: [],
|
|
||||||
links: [],
|
|
||||||
metadata: {},
|
|
||||||
});
|
|
||||||
for (const [index, item] of culling.nodes.entries())
|
|
||||||
await root.dispatch({ type: "node.add", nodeId: item.id, nodeType: item.executor.key,
|
|
||||||
position: { x: 40 + (index % 6) * 280, y: 120 + Math.floor(index / 6) * 260 } });
|
|
||||||
const authoredNodes = new Map(culling.nodes.map((node) => [node.id, node]));
|
|
||||||
const socketKey = (nodeId, semantic, direction) => {
|
|
||||||
const type = authoredNodes.get(nodeId)?.executor.key;
|
|
||||||
const sockets = fxNodeComposition.nodes[type]?.sockets ?? {};
|
|
||||||
const matches = Object.entries(sockets).filter(
|
|
||||||
([key, socket]) =>
|
|
||||||
socket.direction === direction &&
|
|
||||||
(key === semantic || socket.title === semantic),
|
|
||||||
);
|
|
||||||
return matches.length === 1 ? matches[0][0] : semantic;
|
|
||||||
};
|
|
||||||
const links = culling.nodes.flatMap((item) => Object.entries(item.inputs).flatMap(([socket, sources]) =>
|
|
||||||
sources.map((from, index) => [from.node, from.socket, item.id, socket, index])));
|
|
||||||
for (const [a, as, b, bs, index] of links) {
|
|
||||||
const id = `${a}_${as}_${b}_${bs}_${index}`;
|
|
||||||
await root.dispatch({
|
|
||||||
type: "link.add",
|
|
||||||
link: {
|
|
||||||
id,
|
|
||||||
fromNodeId: a,
|
|
||||||
fromSocketId: `${a}:${socketKey(a, as, "output")}`,
|
|
||||||
toNodeId: b,
|
|
||||||
toSocketId: `${b}:${socketKey(b, bs, "input")}`,
|
|
||||||
muted: false,
|
|
||||||
extensions: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const authored = await root.getState();
|
|
||||||
for (const item of culling.nodes) {
|
|
||||||
const target = authored.nodes.find((candidate) => candidate.id === item.id);
|
|
||||||
if (item.executor.key === "texture") {
|
|
||||||
const texture = item.parameters.texture;
|
|
||||||
const relative = texture.extent.kind === "surface_relative";
|
|
||||||
const values = {
|
|
||||||
residency: item.parameters.residency,
|
|
||||||
format: texture.format,
|
|
||||||
dimension: texture.dimension,
|
|
||||||
extentMode: texture.extent.kind,
|
|
||||||
absoluteWidth: relative ? 1 : texture.extent.width,
|
|
||||||
absoluteHeight: relative ? 1 : texture.extent.height,
|
|
||||||
relativeWidthNumerator: relative ? texture.extent.width.numerator : 1,
|
|
||||||
relativeWidthDenominator: relative ? texture.extent.width.denominator : 1,
|
|
||||||
relativeHeightNumerator: relative ? texture.extent.height.numerator : 1,
|
|
||||||
relativeHeightDenominator: relative ? texture.extent.height.denominator : 1,
|
|
||||||
depthOrArrayLayers: texture.extent.depthOrArrayLayers,
|
|
||||||
mipLevelCount: texture.mipLevelCount,
|
|
||||||
sampleCount: String(texture.sampleCount),
|
|
||||||
viewFormat: texture.viewFormats[0] ?? "none",
|
|
||||||
};
|
|
||||||
for (const [key, value] of Object.entries(values))
|
|
||||||
target.parameters[key].value = structuredClone(value);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const [key, value] of Object.entries(item.parameters)) {
|
|
||||||
const input = key.endsWith("Default") ? key.slice(0, -7) : null;
|
|
||||||
if (input) {
|
|
||||||
const socket = target.sockets.find((candidate) => candidate.key === input);
|
|
||||||
if (socket?.defaultValue) socket.defaultValue.value = structuredClone(value);
|
|
||||||
} else {
|
|
||||||
const authoredKey = item.executor.key === "frustum_cull" && key === "camera" ? "cameraSelection" : key;
|
|
||||||
if (target.parameters[authoredKey]) target.parameters[authoredKey].value = structuredClone(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await root.setState(authored);
|
|
||||||
}
|
|
||||||
export async function createRenderGraphEditor(canvas) {
|
|
||||||
const allocateId = createNodeIdAllocator();
|
|
||||||
let root,
|
|
||||||
view,
|
|
||||||
menu,
|
|
||||||
destroying,
|
|
||||||
dead = false;
|
|
||||||
const requestAddNode = Object.assign(
|
|
||||||
async (request, point, isCurrent = () => true) => {
|
|
||||||
let typeId;
|
|
||||||
try {
|
|
||||||
typeId = await menu?.open(point);
|
|
||||||
} catch (error) {
|
|
||||||
if (!dead && isCurrent()) console.error(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dead || !isCurrent() || !root || !view) return;
|
|
||||||
const alive = () => !dead && isCurrent();
|
|
||||||
try {
|
|
||||||
await spawnRequestedNode(
|
|
||||||
root,
|
|
||||||
view,
|
|
||||||
request,
|
|
||||||
typeId,
|
|
||||||
allocateId,
|
|
||||||
alive,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (!dead) console.error(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ close: () => menu?.close() },
|
|
||||||
);
|
|
||||||
const host = prepareBrowserHost(canvas, { requestAddNode });
|
|
||||||
const destroy = () =>
|
|
||||||
(destroying ??= (async () => {
|
|
||||||
dead = true;
|
|
||||||
host.destroy();
|
|
||||||
menu?.destroy();
|
|
||||||
try {
|
|
||||||
await view?.detach();
|
|
||||||
} finally {
|
|
||||||
root?.destroy();
|
|
||||||
view = undefined;
|
|
||||||
root = undefined;
|
|
||||||
}
|
|
||||||
})());
|
|
||||||
try {
|
|
||||||
root = await createFxNode({
|
|
||||||
applicationId: "yawn.render-graph",
|
|
||||||
applicationVersion: CATALOG_VERSION,
|
|
||||||
resources: {},
|
|
||||||
});
|
|
||||||
await root.loadComposition(fxNodeComposition);
|
|
||||||
await seed(root);
|
|
||||||
view = await root.attachView({
|
|
||||||
canvas,
|
|
||||||
viewport: host.initialViewport,
|
|
||||||
initialCamera: { center: { x: 780, y: 340 }, zoom: 0.34 },
|
|
||||||
});
|
|
||||||
menu = createAddNodeMenu(canvas.ownerDocument);
|
|
||||||
host.attach(root, view);
|
|
||||||
await view.whenRendered();
|
|
||||||
const editorRoot = root,
|
|
||||||
editorView = view;
|
|
||||||
return {
|
|
||||||
getState: () => editorRoot.getState(),
|
|
||||||
onSnapshots: (fn) =>
|
|
||||||
editorRoot.onSnapshots((event) => fn(event.snapshot, event.version)),
|
|
||||||
whenRendered: () => editorView.whenRendered(),
|
|
||||||
destroy,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
await destroy().catch(() => {});
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/** Allocates an example-local bounded FXNode ID, reserving candidates for this session. */
|
|
||||||
export function createNodeIdAllocator(randomUUID = () => crypto.randomUUID()) {
|
|
||||||
const reserved = new Set();
|
|
||||||
return (existingIds) => {
|
|
||||||
const existing = new Set(existingIds);
|
|
||||||
for (let attempt = 0; attempt < 64; attempt++) {
|
|
||||||
const id = `node_${randomUUID().replaceAll("-", "")}`;
|
|
||||||
if (/^node_[A-Za-z0-9_]+$/.test(id) && id.length <= 128 && !existing.has(id) && !reserved.has(id)) {
|
|
||||||
reserved.add(id);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("Unable to allocate a unique node ID");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adds exactly one node when the request still targets the loaded composition. */
|
|
||||||
export async function spawnRequestedNode(root, view, request, typeId, allocateId, isCurrent = () => true) {
|
|
||||||
const current = () =>
|
|
||||||
isCurrent() && request.compositionRevision === view.getHostSnapshot().compositionRevision;
|
|
||||||
if (!typeId || !current()) return false;
|
|
||||||
let state;
|
|
||||||
try {
|
|
||||||
state = await root.getState();
|
|
||||||
} catch (error) {
|
|
||||||
if (!isCurrent()) return false;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if (!current()) return false;
|
|
||||||
const nodeId = allocateId(state.nodes.map((node) => node.id));
|
|
||||||
if (!current()) return false;
|
|
||||||
try {
|
|
||||||
await view.addNode(
|
|
||||||
{ typeId, nodeId, viewPosition: request.viewPosition },
|
|
||||||
{ expectedVersion: state.version },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (!isCurrent()) return false;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { defaultPipelines } from "@yawn/default-pipelines";
|
|
||||||
import { descriptors } from "@yawn/render-graph-fxnode/catalog";
|
|
||||||
import { graphFromObject } from "@yawn/render-graph-js";
|
|
||||||
|
|
||||||
// A complete graph authored like a package consumer would author it.
|
|
||||||
|
|
||||||
const input = (node, socket) => [{ node, socket }];
|
|
||||||
const node = (id, key, parameters = {}, inputs = {}) => ({
|
|
||||||
id,
|
|
||||||
state: "enabled",
|
|
||||||
executor: { key, version: descriptors[key].version },
|
|
||||||
parameters,
|
|
||||||
inputs,
|
|
||||||
});
|
|
||||||
const texture = (format) => ({
|
|
||||||
texture: {
|
|
||||||
dimension: "d2",
|
|
||||||
format,
|
|
||||||
extent: {
|
|
||||||
kind: "surface_relative",
|
|
||||||
width: { numerator: 1, denominator: 1 },
|
|
||||||
height: { numerator: 1, denominator: 1 },
|
|
||||||
depthOrArrayLayers: 1,
|
|
||||||
},
|
|
||||||
mipLevelCount: 1,
|
|
||||||
sampleCount: 1,
|
|
||||||
viewFormats: [],
|
|
||||||
},
|
|
||||||
residency: "transient",
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodes = [
|
|
||||||
node("hdr", "texture", texture("rgba16_float")),
|
|
||||||
node("scene_depth", "texture", texture("depth32_float")),
|
|
||||||
node("mesh", "mesh"),
|
|
||||||
node("type_words", "separate_u32x16", { valueDefault: Array(16).fill(0) }, { value: input("mesh", "type") }),
|
|
||||||
node("type_bits", "separate_u32_bits", { valueDefault: 0 }, { value: input("type_words", "word0") }),
|
|
||||||
node("ground_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit1")] }),
|
|
||||||
node("not_double", "not", { operandDefault: false }, { operand: input("type_bits", "bit3") }),
|
|
||||||
node("standard_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit2"), ...input("not_double", "value")] }),
|
|
||||||
node("double_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit3")] }),
|
|
||||||
node("cull", "frustum_cull", { camera: "active" }, { mesh: input("mesh", "mesh"), localAabb: input("mesh", "localAabb") }),
|
|
||||||
node("not_culled", "not", { operandDefault: false }, { operand: input("cull", "isFrustumCulled") }),
|
|
||||||
];
|
|
||||||
for (const id of ["ground_class", "standard_class", "double_class"])
|
|
||||||
nodes.find((item) => item.id === id).inputs.inputs.push(...input("not_culled", "value"));
|
|
||||||
nodes.push(
|
|
||||||
node("ground", "ground_plane", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1], predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input("ground_class", "value"), color: input("hdr", "texture"), depth: input("scene_depth", "texture") }),
|
|
||||||
node("pbr", "gltf_standard", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1], predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input("standard_class", "value"), color: input("hdr", "texture"), depth: input("scene_depth", "texture") }),
|
|
||||||
node("pbr_double", "gltf_standard_double_sided", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1], predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input("double_class", "value"), color: input("hdr", "texture"), depth: input("scene_depth", "texture") }),
|
|
||||||
node("frame_out", "frame_out", { surfaceFormat: "preferred", hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] }, { color: input("pbr_double", "color") }),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** The example's JSO graph; the graph addon canonicalizes it to AST and S-expressions. */
|
|
||||||
export const culling = graphFromObject({
|
|
||||||
id: "example_jso_scene",
|
|
||||||
revision: 1,
|
|
||||||
pipelines: defaultPipelines,
|
|
||||||
nodes,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const renderGraphPresets = Object.freeze({ jso: culling });
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
const MIN_DISTANCE = 0.1;
|
|
||||||
const MAX_PITCH = Math.PI / 2 - 0.01;
|
|
||||||
|
|
||||||
// Browser controls map straight onto the packed camera SOA row without messages.
|
|
||||||
export function installCameraRenderDataControls(core, canvas) {
|
|
||||||
const camera = core.array("camera.state");
|
|
||||||
const abort = new AbortController();
|
|
||||||
const options = { signal: abort.signal };
|
|
||||||
const write = (state) => camera.write(0, state);
|
|
||||||
|
|
||||||
canvas.addEventListener(
|
|
||||||
"pointerdown",
|
|
||||||
(event) => {
|
|
||||||
if (event.pointerType === "mouse" && (event.button === 1 || event.button === 2)) {
|
|
||||||
event.preventDefault();
|
|
||||||
canvas.setPointerCapture(event.pointerId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
canvas.addEventListener(
|
|
||||||
"pointermove",
|
|
||||||
(event) => {
|
|
||||||
if (
|
|
||||||
event.pointerType !== "mouse" ||
|
|
||||||
!canvas.hasPointerCapture(event.pointerId) ||
|
|
||||||
(event.buttons & 6) === 0
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
const state = camera.read(0);
|
|
||||||
const offset = [state[0] - state[4], state[1] - state[5], state[2] - state[6]];
|
|
||||||
const distance = Math.hypot(...offset);
|
|
||||||
if ((event.buttons & 4) !== 0) {
|
|
||||||
const yaw = Math.atan2(offset[0], offset[2]) + event.movementX * 0.005;
|
|
||||||
const pitch = Math.max(
|
|
||||||
-MAX_PITCH,
|
|
||||||
Math.min(
|
|
||||||
MAX_PITCH,
|
|
||||||
Math.asin(offset[1] / distance) + event.movementY * 0.005,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const horizontal = Math.cos(pitch) * distance;
|
|
||||||
state[0] = state[4] + Math.sin(yaw) * horizontal;
|
|
||||||
state[1] = state[5] + Math.sin(pitch) * distance;
|
|
||||||
state[2] = state[6] + Math.cos(yaw) * horizontal;
|
|
||||||
} else {
|
|
||||||
const forward = [
|
|
||||||
(state[4] - state[0]) / distance,
|
|
||||||
(state[5] - state[1]) / distance,
|
|
||||||
(state[6] - state[2]) / distance,
|
|
||||||
];
|
|
||||||
const right = [
|
|
||||||
forward[1] * state[10] - forward[2] * state[9],
|
|
||||||
forward[2] * state[8] - forward[0] * state[10],
|
|
||||||
forward[0] * state[9] - forward[1] * state[8],
|
|
||||||
];
|
|
||||||
const rightLength = Math.hypot(...right);
|
|
||||||
right.forEach((value, index) => (right[index] = value / rightLength));
|
|
||||||
const up = [
|
|
||||||
right[1] * forward[2] - right[2] * forward[1],
|
|
||||||
right[2] * forward[0] - right[0] * forward[2],
|
|
||||||
right[0] * forward[1] - right[1] * forward[0],
|
|
||||||
];
|
|
||||||
const units =
|
|
||||||
(2 * distance * Math.tan(state[12] * 0.5)) /
|
|
||||||
Math.max(1, canvas.clientHeight);
|
|
||||||
const translation = right.map(
|
|
||||||
(value, index) =>
|
|
||||||
-value * event.movementX * units + up[index] * event.movementY * units,
|
|
||||||
);
|
|
||||||
for (let index = 0; index < 3; index++) {
|
|
||||||
state[index] += translation[index];
|
|
||||||
state[index + 4] += translation[index];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
write(state);
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
for (const type of ["pointerup", "pointercancel"]) {
|
|
||||||
canvas.addEventListener(
|
|
||||||
type,
|
|
||||||
(event) => {
|
|
||||||
if (canvas.hasPointerCapture(event.pointerId)) {
|
|
||||||
canvas.releasePointerCapture(event.pointerId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
canvas.addEventListener(
|
|
||||||
"wheel",
|
|
||||||
(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const delta =
|
|
||||||
event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
|
||||||
? event.deltaY * 16
|
|
||||||
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
|
||||||
? event.deltaY * canvas.clientHeight
|
|
||||||
: event.deltaY;
|
|
||||||
if (!Number.isFinite(delta) || delta === 0) return;
|
|
||||||
const state = camera.read(0);
|
|
||||||
const offset = [state[0] - state[4], state[1] - state[5], state[2] - state[6]];
|
|
||||||
const distance = Math.hypot(...offset);
|
|
||||||
const nextDistance = Math.max(
|
|
||||||
MIN_DISTANCE,
|
|
||||||
Math.min(state[15] * 0.95, distance * Math.exp(0.002 * delta)),
|
|
||||||
);
|
|
||||||
for (let index = 0; index < 3; index++) {
|
|
||||||
state[index] = state[index + 4] + offset[index] * (nextDistance / distance);
|
|
||||||
}
|
|
||||||
write(state);
|
|
||||||
},
|
|
||||||
{ ...options, passive: false },
|
|
||||||
);
|
|
||||||
canvas.addEventListener("contextmenu", (event) => event.preventDefault(), options);
|
|
||||||
|
|
||||||
return () => abort.abort();
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/** Create the worker bridge expected by YawnCore for one OffscreenCanvas. */
|
|
||||||
export function createWorkerTransport(canvas) {
|
|
||||||
if (!(canvas instanceof HTMLCanvasElement)) {
|
|
||||||
throw new TypeError("canvas must be an HTMLCanvasElement");
|
|
||||||
}
|
|
||||||
const dimensions = () => {
|
|
||||||
const dpr = devicePixelRatio;
|
|
||||||
return {
|
|
||||||
dpr,
|
|
||||||
width: Math.max(1, canvas.clientWidth),
|
|
||||||
height: Math.max(1, canvas.clientHeight),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const initial = dimensions();
|
|
||||||
canvas.width = Math.round(initial.width * initial.dpr);
|
|
||||||
canvas.height = Math.round(initial.height * initial.dpr);
|
|
||||||
const worker = new Worker(
|
|
||||||
new URL(
|
|
||||||
"../../renderer/src/platform/web/worker/mainWorker.js",
|
|
||||||
import.meta.url,
|
|
||||||
),
|
|
||||||
{ type: "module", name: "yawn-renderer" },
|
|
||||||
);
|
|
||||||
const resize = () => {
|
|
||||||
const { dpr, width, height } = dimensions();
|
|
||||||
worker.postMessage({
|
|
||||||
type: "window-event",
|
|
||||||
kind: 0,
|
|
||||||
values: new Float64Array([width, height, dpr]),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const abort = new AbortController();
|
|
||||||
addEventListener("resize", resize, { signal: abort.signal });
|
|
||||||
const offscreen = canvas.transferControlToOffscreen();
|
|
||||||
worker.postMessage({ type: "init", canvas: offscreen }, [offscreen]);
|
|
||||||
return {
|
|
||||||
worker,
|
|
||||||
free() {
|
|
||||||
abort.abort();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Generated
+8
-3747
File diff suppressed because it is too large
Load Diff
+4
-33
@@ -1,42 +1,13 @@
|
|||||||
{
|
{
|
||||||
"type": "module",
|
"name": "yawn",
|
||||||
"author": "ecoricemon",
|
|
||||||
"name": "basic",
|
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"type": "module",
|
||||||
"packages/*",
|
"workspaces": ["packages/*", "addons/*"],
|
||||||
"addons/*"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"examples": "run-s rsw:build examples:watch",
|
"start": "vitepress dev docs --host 0.0.0.0 --port ${PORT:-8080}"
|
||||||
"examples:watch": "run-p rsw:watch examples:vite docs:vite",
|
|
||||||
"examples:vite": "vite dev",
|
|
||||||
"docs": "npm run examples",
|
|
||||||
"docs:vite": "vitepress dev docs --host 127.0.0.1 --port 5174",
|
|
||||||
"docs:build": "vitepress build docs",
|
|
||||||
"docs:preview": "vitepress preview docs --host 127.0.0.1 --port 5174",
|
|
||||||
"rsw:watch": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' rsw watch",
|
|
||||||
"rsw:build": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' rsw build",
|
|
||||||
"wasm-dev": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --dev --target web renderer -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
|
|
||||||
"wasm-release": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --release --target web renderer -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
|
|
||||||
"bundle-dev": "vite build --mode development",
|
|
||||||
"bundle-release": "vite build",
|
|
||||||
"build": "run-s clean wasm-dev bundle-dev docs:build",
|
|
||||||
"build-release": "run-s clean wasm-release bundle-release docs:build",
|
|
||||||
"test:js": "node --test tests/*.test.js",
|
|
||||||
"start": "vite preview",
|
|
||||||
"clean": "rimraf --glob dist **/pkg",
|
|
||||||
"clean-all": "rimraf --glob dist **/pkg target node_modules"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wasm-tool/wasm-pack-plugin": "^1.7.0",
|
|
||||||
"npm-run-all": "^4.1.5",
|
|
||||||
"rimraf": "^5.0.1",
|
|
||||||
"rollup-plugin-copy": "^3.5.0",
|
|
||||||
"vite": "^7.1.10",
|
|
||||||
"vite-plugin-rsw": "^2.0.11",
|
|
||||||
"vite-plugin-wasm": "^3.3.0",
|
|
||||||
"vitepress": "^1.6.4"
|
"vitepress": "^1.6.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
"name": "@yawn/core",
|
"name": "@yawn/core",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./src/index.js"
|
"exports": "./src/index.js",
|
||||||
|
"files": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-398
@@ -1,420 +1,100 @@
|
|||||||
const HEADER_WORDS = 16, SLOT_WORDS = 40, CAPACITY = 1024, SLOT_VERSION = 2;
|
const TYPES = { f32: Float32Array, u32: Uint32Array, i32: Int32Array };
|
||||||
const OP = { INSTALL_RENDER_DATA: 1, CREATE_INSTANCE: 3, DESTROY_INSTANCE: 6, COMPILE_GRAPH: 7, DROP_GRAPH: 8, SWITCH_GRAPH: 9, ALLOCATE_SOA: 11 };
|
|
||||||
|
|
||||||
export class RendererError extends Error {
|
export class SharedRows {
|
||||||
constructor(code, details) { super(details?.message ?? code); this.name = "RendererError"; this.code = code; this.details = details; }
|
constructor(buffer, descriptor) {
|
||||||
}
|
this.buffer = buffer;
|
||||||
|
this.descriptor = Object.freeze(descriptor);
|
||||||
export class YawnCore extends EventTarget {
|
|
||||||
#bridge; #worker; #header; #slots; #buffer; #next = 1; #payload = 1;
|
|
||||||
#pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false;
|
|
||||||
#readyResolve; #readyReject; #arrays = new Map();
|
|
||||||
#transportReady = false;
|
|
||||||
#telemetry; #stopped = false; #renderDataSnapshot;
|
|
||||||
#graphQueue = []; #graphBusy = false;
|
|
||||||
|
|
||||||
constructor(bridge) {
|
|
||||||
super();
|
|
||||||
this.#bridge = bridge;
|
|
||||||
this.#worker = bridge.worker;
|
|
||||||
this.#ready = new Promise((resolve, reject) => {
|
|
||||||
this.#readyResolve = resolve;
|
|
||||||
this.#readyReject = reject;
|
|
||||||
});
|
|
||||||
if (bridge.memory && Number.isInteger(bridge.ringPtr)) {
|
|
||||||
this.#installTransport(bridge.memory, bridge.ringPtr);
|
|
||||||
}
|
|
||||||
this.#worker.addEventListener("message", e => this.#message(e.data));
|
|
||||||
this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR"));
|
|
||||||
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_MESSAGE_ERROR"));
|
|
||||||
this.#worker.start?.();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get ready() { return this.#ready; }
|
get name() { return this.descriptor.name; }
|
||||||
get telemetry() { return this.#telemetry; }
|
get rows() { return this.descriptor.rows; }
|
||||||
get renderDataSnapshot() { return this.#renderDataSnapshot; }
|
get stride() { return this.descriptor.stride; }
|
||||||
|
get format() { return this.descriptor.format; }
|
||||||
|
get view() {
|
||||||
|
return new TYPES[this.format](this.buffer, this.descriptor.offset, this.rows * this.stride / 4);
|
||||||
|
}
|
||||||
|
|
||||||
array(name) {
|
row(index) {
|
||||||
const array = this.#arrays.get(name);
|
if (!Number.isInteger(index) || index < 0 || index >= this.rows) throw new RangeError("ROW_RANGE");
|
||||||
if (!array) throw new RendererError("SOA_ARRAY_UNKNOWN", { message: `Unknown shared array '${name}'` });
|
const width = this.stride / 4;
|
||||||
|
return this.view.subarray(index * width, (index + 1) * width);
|
||||||
|
}
|
||||||
|
|
||||||
|
read(index) { return Array.from(this.row(index)); }
|
||||||
|
write(index, values) {
|
||||||
|
const row = this.row(index);
|
||||||
|
if (!values || values.length !== row.length) throw new RangeError("ROW_WIDTH");
|
||||||
|
row.set(values);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
share() { return { buffer: this.buffer, descriptor: this.descriptor }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YawnCore {
|
||||||
|
#worker;
|
||||||
|
#buffer;
|
||||||
|
#arrays = new Map();
|
||||||
|
#pending = new Map();
|
||||||
|
#next = 1;
|
||||||
|
|
||||||
|
constructor(canvas, { arenaBytes = 64 * 1024 * 1024, workerFactory } = {}) {
|
||||||
|
if (!canvas) throw new TypeError("canvas is required");
|
||||||
|
this.#worker = workerFactory?.() ?? new Worker(new URL("./worker.js", import.meta.url), {
|
||||||
|
type: "module",
|
||||||
|
name: "yawn-core",
|
||||||
|
});
|
||||||
|
this.#worker.addEventListener("message", ({ data }) => this.#message(data));
|
||||||
|
this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR"));
|
||||||
|
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_ERROR"));
|
||||||
|
this.#worker.start?.();
|
||||||
|
const offscreen = canvas.transferControlToOffscreen?.() ?? canvas;
|
||||||
|
this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen])
|
||||||
|
.then(({ buffer }) => { this.#buffer = buffer; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async allocateRows({ name, rows, stride, format }) {
|
||||||
|
await this.ready;
|
||||||
|
const descriptor = await this.#request("allocate", { name, rows, stride, format });
|
||||||
|
const array = new SharedRows(this.#buffer, descriptor);
|
||||||
|
this.#arrays.set(name, array);
|
||||||
return array;
|
return array;
|
||||||
}
|
}
|
||||||
|
|
||||||
async allocateArray(layout) {
|
array(name) {
|
||||||
await this.#ready;
|
const array = this.#arrays.get(name);
|
||||||
let source;
|
if (!array) throw new Error(`UNKNOWN_ARRAY: ${name}`);
|
||||||
try { source = JSON.stringify(layout); }
|
return array;
|
||||||
catch (error) { throw new RendererError("SOA_LAYOUT_INVALID", { message: error?.message }); }
|
|
||||||
const descriptor = await this.#withPayload(new TextEncoder().encode(source).buffer, OP.ALLOCATE_SOA);
|
|
||||||
return this.#installArray(descriptor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#refreshViews() {
|
async loadGraph(serialized) {
|
||||||
if (!this.#bridge.memory || !Number.isInteger(this.#bridge.ringPtr)) return;
|
await this.ready;
|
||||||
const buffer = this.#bridge.memory.buffer;
|
return this.#request("load-graph", { serialized });
|
||||||
if (buffer === this.#buffer) return;
|
|
||||||
this.#buffer = buffer;
|
|
||||||
this.#header = new Int32Array(buffer, this.#bridge.ringPtr, HEADER_WORDS);
|
|
||||||
this.#slots = new Int32Array(buffer, this.#bridge.ringPtr + 64, CAPACITY * SLOT_WORDS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#installTransport(memory, ringPtr) {
|
#request(type, payload, transfer = []) {
|
||||||
this.#bridge.memory = memory;
|
const request = this.#next++;
|
||||||
this.#bridge.ringPtr = ringPtr;
|
return new Promise((resolve, reject) => {
|
||||||
this.#refreshViews();
|
this.#pending.set(request, { resolve, reject });
|
||||||
if (Atomics.load(this.#header, 0) !== 0x4e574159 || Atomics.load(this.#header, 1) !== 2 || Atomics.load(this.#header, 2) !== CAPACITY || Atomics.load(this.#header, 3) !== SLOT_WORDS) {
|
this.#worker.postMessage({ type, request, ...payload }, transfer);
|
||||||
const actual = Array.from(this.#header.subarray(0, 4), value => value >>> 0);
|
});
|
||||||
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
|
|
||||||
try { this.#bridge?.free?.(); } catch { /* best effort */ }
|
|
||||||
throw new RendererError("PROTOCOL_MISMATCH", { message: `Invalid command ring at ${ringPtr}: ${actual.join(",")}` });
|
|
||||||
}
|
|
||||||
this.#transportReady = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#message(message) {
|
#message(message) {
|
||||||
if (message?.type === "bootstrap") {
|
const pending = this.#pending.get(message?.request);
|
||||||
if (this.#transportReady) { this.#fail("PROTOCOL_MISMATCH"); return; }
|
if (!pending) return;
|
||||||
try { this.#installTransport(message.memory, message.ringPtr); }
|
this.#pending.delete(message.request);
|
||||||
catch (error) { this.#readyReject?.(error); this.#fail(error.code || "PROTOCOL_MISMATCH"); }
|
if (message.error) pending.reject(Object.assign(new Error(message.error), { code: message.error }));
|
||||||
} else if (message?.type === "reply") {
|
else pending.resolve(message.result);
|
||||||
const pending = this.#pending.get(message.request);
|
|
||||||
if (!pending) return;
|
|
||||||
this.#pending.delete(message.request);
|
|
||||||
message.ok ? pending.resolve(message.result) : pending.reject(new RendererError(message.code, message.details));
|
|
||||||
} else if (message?.type === "payload-ready") {
|
|
||||||
const pending = this.#payloadPending.get(message.id);
|
|
||||||
if (pending) { this.#payloadPending.delete(message.id); pending.resolve(); }
|
|
||||||
} else if (message?.type === "telemetry") {
|
|
||||||
this.#telemetry = message;
|
|
||||||
this.dispatchEvent(new CustomEvent("renderer-frame", { detail: message }));
|
|
||||||
} else if (message?.type === "fatal") {
|
|
||||||
console.error("renderer worker fatal", JSON.stringify(message));
|
|
||||||
this.#fail(message.code || "WORKER_FATAL");
|
|
||||||
} else if (message?.type === "soa-init" || message?.type === "soa-layout") {
|
|
||||||
try {
|
|
||||||
for (const descriptor of message.arrays ?? []) this.#installArray(descriptor);
|
|
||||||
if (message.type === "soa-init") this.#readyResolve?.(this);
|
|
||||||
this.dispatchEvent(new CustomEvent("yawn-soa-layout", { detail: this.#arrays }));
|
|
||||||
} catch (error) {
|
|
||||||
this.#readyReject?.(error);
|
|
||||||
this.#fail("SOA_PROTOCOL_MISMATCH");
|
|
||||||
}
|
|
||||||
} else if (message?.type === "snapshot-init") {
|
|
||||||
try {
|
|
||||||
if (message.controlVersion !== 1 || message.schemaVersion !== 2) throw new Error("version");
|
|
||||||
this.#renderDataSnapshot = Object.freeze({
|
|
||||||
memory: this.#bridge.memory,
|
|
||||||
controlPtr: message.controlPtr,
|
|
||||||
controlVersion: message.controlVersion,
|
|
||||||
schemaVersion: message.schemaVersion,
|
|
||||||
});
|
|
||||||
this.dispatchEvent(new CustomEvent("yawn-render-data-snapshot", { detail: this.#renderDataSnapshot }));
|
|
||||||
} catch { this.#fail("SNAPSHOT_PROTOCOL_MISMATCH"); }
|
|
||||||
} else if (message?.type === "snapshot-published") {
|
|
||||||
this.dispatchEvent(new CustomEvent("yawn-render-data-snapshot-published", {
|
|
||||||
detail: Object.freeze({ epoch: message.epoch >>> 0 }),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#installArray(descriptor) {
|
|
||||||
const existing = this.#arrays.get(descriptor?.name);
|
|
||||||
if (existing) existing.update(descriptor);
|
|
||||||
else this.#arrays.set(descriptor?.name, new SharedSoaArray(this.#bridge.memory, descriptor));
|
|
||||||
return this.#arrays.get(descriptor.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
#stop() {
|
|
||||||
if (this.#stopped) return;
|
|
||||||
this.#stopped = true;
|
|
||||||
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
|
|
||||||
try { this.#bridge?.free?.(); } catch { /* best effort */ }
|
|
||||||
this.#bridge = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#fail(code) {
|
#fail(code) {
|
||||||
if (this.#disposed) { this.#stop(); return; }
|
for (const { reject } of this.#pending.values()) reject(Object.assign(new Error(code), { code }));
|
||||||
this.#disposed = true;
|
|
||||||
const error = new RendererError(code);
|
|
||||||
this.#readyReject?.(error);
|
|
||||||
for (const pending of this.#pending.values()) pending.reject(error);
|
|
||||||
this.#pending.clear();
|
this.#pending.clear();
|
||||||
for (const pending of this.#payloadPending.values()) pending.reject(error);
|
|
||||||
this.#payloadPending.clear();
|
|
||||||
for (const pending of this.#graphQueue) pending.reject(error);
|
|
||||||
this.#graphQueue.length = 0;
|
|
||||||
this.#stop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#corrupt(code) {
|
dispose() {
|
||||||
Atomics.store(this.#header, 6, 1);
|
this.#fail("DISPOSED");
|
||||||
this.#fail(code);
|
this.#worker.terminate();
|
||||||
return Promise.reject(new RendererError(code));
|
|
||||||
}
|
|
||||||
|
|
||||||
#enqueue(opcode, words = []) {
|
|
||||||
if (this.#disposed) return Promise.reject(new RendererError("DISPOSED"));
|
|
||||||
this.#refreshViews();
|
|
||||||
if (Atomics.load(this.#header, 6) !== 0) return this.#corrupt("RING_CLOSED");
|
|
||||||
const read = Atomics.load(this.#header, 4) >>> 0;
|
|
||||||
const write = Atomics.load(this.#header, 5) >>> 0;
|
|
||||||
const backlog = (write - read) >>> 0;
|
|
||||||
if (backlog > CAPACITY) return this.#corrupt("RING_CORRUPT");
|
|
||||||
if (backlog === CAPACITY) return Promise.reject(new RendererError("RING_FULL"));
|
|
||||||
let request = this.#next++ >>> 0;
|
|
||||||
if (request === 0) { request = 1; this.#next = 2; }
|
|
||||||
const base = (write % CAPACITY) * SLOT_WORDS;
|
|
||||||
const promise = new Promise((resolve, reject) => this.#pending.set(request, { resolve, reject }));
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < SLOT_WORDS; i++) Atomics.store(this.#slots, base + i, 0);
|
|
||||||
for (let i = 0; i < words.length; i++) Atomics.store(this.#slots, base + 3 + i, words[i]);
|
|
||||||
Atomics.store(this.#slots, base + 2, request);
|
|
||||||
Atomics.store(this.#slots, base + 1, opcode);
|
|
||||||
// The slot tag is its publication marker; write_index publishes the complete slot.
|
|
||||||
Atomics.store(this.#slots, base, SLOT_VERSION);
|
|
||||||
Atomics.store(this.#header, 5, (write + 1) | 0);
|
|
||||||
Atomics.notify(this.#header, 5);
|
|
||||||
} catch (error) {
|
|
||||||
this.#pending.delete(request);
|
|
||||||
this.#fail("PUBLICATION_FAILED");
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
return promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
commitRenderDataUpload(array, byteLength) {
|
|
||||||
if (this.#disposed) throw new RendererError("DISPOSED");
|
|
||||||
if (!(array instanceof SharedSoaArray) || array.domain !== "fixed" || array.scalar !== "u32" || array.stride !== array.lanes * 4)
|
|
||||||
throw new TypeError("array must be a packed fixed uint32 shared array");
|
|
||||||
if (!Number.isInteger(byteLength) || byteLength < 1 || byteLength > array.length * array.lanes * 4)
|
|
||||||
throw new RangeError("byteLength is outside the shared array");
|
|
||||||
return this.#enqueue(OP.INSTALL_RENDER_DATA, [array.id, byteLength]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createInstance(mesh, transform, { type = Array(16).fill(0) } = {}) {
|
|
||||||
validateHandle(mesh, "mesh");
|
|
||||||
const result = await this.#enqueue(OP.CREATE_INSTANCE, [...mesh, ...floatWords(transform), ...typeWords(type)]);
|
|
||||||
validateHandle(result, "instance");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInstanceTransform(instance, transform) {
|
|
||||||
this.#validateLiveInstance(instance);
|
|
||||||
this.array("instance.transform").write(instance[0], floatValues(transform), instance[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setInstanceType(instance, type) {
|
|
||||||
this.#validateLiveInstance(instance);
|
|
||||||
this.array("instance.type").write(instance[0], typeWords(type), instance[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroyInstance(instance) {
|
|
||||||
this.#validateLiveInstance(instance);
|
|
||||||
return this.#enqueue(OP.DESTROY_INSTANCE, instance);
|
|
||||||
}
|
|
||||||
|
|
||||||
#validateLiveInstance(instance) {
|
|
||||||
validateHandle(instance, "instance");
|
|
||||||
const generation = this.array("instance.generation").read(instance[0])[0];
|
|
||||||
if (generation !== instance[1]) throw new RendererError("STALE_HANDLE");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async #withPayload(buffer, opcode, words = []) {
|
|
||||||
if (this.#disposed) throw new RendererError("DISPOSED");
|
|
||||||
let id;
|
|
||||||
do { id = this.#payload++ >>> 0; if (!id) id = this.#payload++ >>> 0; }
|
|
||||||
while (!id || this.#payloadActive.has(id));
|
|
||||||
this.#payloadActive.add(id);
|
|
||||||
const worker = this.#worker;
|
|
||||||
const ready = new Promise((resolve, reject) => this.#payloadPending.set(id, { resolve, reject }));
|
|
||||||
try {
|
|
||||||
worker.postMessage({ type: "payload", id, buffer }, [buffer]);
|
|
||||||
await ready;
|
|
||||||
return await this.#enqueue(opcode, [id, ...words]);
|
|
||||||
} finally {
|
|
||||||
this.#payloadPending.delete(id);
|
|
||||||
this.#payloadActive.delete(id);
|
|
||||||
try { worker.postMessage({ type: "payload-release", id }); } catch { /* best effort after termination */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#graphCall(operation) {
|
|
||||||
const result = new Promise((resolve, reject) => this.#graphQueue.push({operation, resolve, reject}));
|
|
||||||
this.#pumpGraphQueue();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pumpGraphQueue() {
|
|
||||||
if (this.#graphBusy || !this.#graphQueue.length) return;
|
|
||||||
const call = this.#graphQueue.shift();
|
|
||||||
if (this.#disposed) { call.reject(new RendererError("DISPOSED")); this.#pumpGraphQueue(); return; }
|
|
||||||
this.#graphBusy = true;
|
|
||||||
let outcome;
|
|
||||||
try { outcome = call.operation(); } catch (error) { outcome = Promise.reject(error); }
|
|
||||||
Promise.resolve(outcome).then(call.resolve, call.reject).finally(() => { this.#graphBusy = false; this.#pumpGraphQueue(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
compileGraph(graph) {
|
|
||||||
return this.#graphCall(() => this.#compileGraph(graph));
|
|
||||||
}
|
|
||||||
|
|
||||||
async #compileGraph(graph) {
|
|
||||||
if (this.#disposed) throw new RendererError("DISPOSED");
|
|
||||||
if (typeof graph !== "string") throw new TypeError("graph must be a serialized render-graph AST");
|
|
||||||
const source = graph;
|
|
||||||
const buffer = new TextEncoder().encode(source).buffer;
|
|
||||||
if (buffer.byteLength > 1024 * 1024) throw new RendererError("GRAPH_PAYLOAD_TOO_LARGE");
|
|
||||||
return this.#withPayload(buffer, OP.COMPILE_GRAPH);
|
|
||||||
}
|
|
||||||
|
|
||||||
dropCompiledGraph(compiledId) {
|
|
||||||
validateCompiledId(compiledId);
|
|
||||||
return this.#graphCall(() => this.#enqueue(OP.DROP_GRAPH, compiledId));
|
|
||||||
}
|
|
||||||
|
|
||||||
switchCompiledGraph(compiledId) {
|
|
||||||
validateCompiledId(compiledId);
|
|
||||||
if (compiledId[0] === 0 && compiledId[1] === 0) throw new TypeError("compiledId must be nonzero");
|
|
||||||
return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, compiledId));
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() { this.#fail("DISPOSED"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateCompiledId(compiledId) {
|
|
||||||
if (!Array.isArray(compiledId) || compiledId.length !== 2 || compiledId.some(word => !Number.isInteger(word) || word < 0 || word > 0xffffffff)) throw new TypeError("compiledId must contain exactly two uint32 values");
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateHandle(handle, name) {
|
|
||||||
if (!Array.isArray(handle) || handle.length !== 2 || handle.some(word => !Number.isInteger(word) || word < 0 || word > 0xffffffff)) throw new TypeError(`${name} handle must contain exactly two uint32 values`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function typeWords(words) { if (!words || words.length !== 16 || [...words].some(x => !Number.isInteger(x) || x < 0 || x > 0xffffffff)) throw new TypeError("type must contain exactly 16 uint32 values"); return Array.from(words, x => x >>> 0); }
|
|
||||||
|
|
||||||
function floatWords(matrix) {
|
|
||||||
return [...new Int32Array(new Float32Array(floatValues(matrix)).buffer)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function floatValues(values) {
|
|
||||||
if (!values || values.length !== 16 || [...values].some(value => typeof value !== "number" || !Number.isFinite(value))) throw new TypeError("transform must contain 16 finite numbers");
|
|
||||||
return Array.from(values);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SOA_MAGIC = 0x414f5359;
|
|
||||||
const SCALAR_TAG = { u32: 1, i32: 2, f32: 3 };
|
|
||||||
|
|
||||||
export class SharedSoaArray {
|
|
||||||
#memory; #descriptor; #buffer; #control; #words;
|
|
||||||
|
|
||||||
constructor(memory, descriptor) {
|
|
||||||
this.#memory = memory;
|
|
||||||
this.update(descriptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() { return this.#descriptor.name; }
|
|
||||||
get id() { return this.#descriptor.id; }
|
|
||||||
get domain() { return this.#descriptor.domain; }
|
|
||||||
get scalar() { return this.#descriptor.scalar; }
|
|
||||||
get lanes() { return this.#descriptor.lanes; }
|
|
||||||
get stride() { return this.#descriptor.stride; }
|
|
||||||
get length() { this.#refresh(); return Atomics.load(this.#control, 6) >>> 0; }
|
|
||||||
get capacity() { return this.#descriptor.capacity; }
|
|
||||||
|
|
||||||
/** Returns the shared backing store and current wire descriptor for another worker. */
|
|
||||||
share() {
|
|
||||||
this.#refresh();
|
|
||||||
return { buffer: this.#memory.buffer, descriptor: { ...this.#descriptor } };
|
|
||||||
}
|
|
||||||
|
|
||||||
update(descriptor) {
|
|
||||||
if (!descriptor || typeof descriptor.name !== "string" || !SCALAR_TAG[descriptor.scalar] || typeof descriptor.writable !== "boolean" || (descriptor.generationGuard !== undefined && descriptor.generationGuard !== "instance" && descriptor.generationGuard !== "mesh"))
|
|
||||||
throw new RendererError("SOA_PROTOCOL_MISMATCH");
|
|
||||||
if (this.#descriptor && (descriptor.id !== this.#descriptor.id || descriptor.layoutEpoch < this.#descriptor.layoutEpoch))
|
|
||||||
throw new RendererError("SOA_PROTOCOL_MISMATCH");
|
|
||||||
this.#descriptor = Object.freeze({ ...descriptor });
|
|
||||||
this.#buffer = null;
|
|
||||||
this.#refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
#refresh() {
|
|
||||||
const buffer = this.#memory.buffer;
|
|
||||||
if (this.#buffer === buffer && this.#control?.byteOffset === this.#descriptor.controlPtr) return;
|
|
||||||
const descriptor = this.#descriptor;
|
|
||||||
if (!(buffer instanceof SharedArrayBuffer) || descriptor.controlPtr % 64 || descriptor.dataOffset !== 64 || descriptor.stride % 16)
|
|
||||||
throw new RendererError("SOA_PROTOCOL_MISMATCH");
|
|
||||||
this.#buffer = buffer;
|
|
||||||
this.#control = new Int32Array(buffer, descriptor.controlPtr, 16);
|
|
||||||
this.#words = new Int32Array(buffer, descriptor.controlPtr + descriptor.dataOffset, descriptor.byteLength / 4);
|
|
||||||
if ((Atomics.load(this.#control, 0) >>> 0) !== SOA_MAGIC || (Atomics.load(this.#control, 1) >>> 0) !== 1 || (Atomics.load(this.#control, 2) >>> 0) !== descriptor.id || (Atomics.load(this.#control, 3) >>> 0) !== SCALAR_TAG[descriptor.scalar])
|
|
||||||
throw new RendererError("SOA_PROTOCOL_MISMATCH");
|
|
||||||
}
|
|
||||||
|
|
||||||
#encode(value) {
|
|
||||||
if (this.scalar === "u32") {
|
|
||||||
if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) throw new TypeError("value must be a uint32");
|
|
||||||
return value | 0;
|
|
||||||
}
|
|
||||||
if (this.scalar === "i32") {
|
|
||||||
if (!Number.isInteger(value) || value < -0x80000000 || value > 0x7fffffff) throw new TypeError("value must be an int32");
|
|
||||||
return value | 0;
|
|
||||||
}
|
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError("value must be a finite float32");
|
|
||||||
return new Int32Array(new Float32Array([value]).buffer)[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
#decode(word) {
|
|
||||||
if (this.scalar === "u32") return word >>> 0;
|
|
||||||
if (this.scalar === "i32") return word | 0;
|
|
||||||
return new Float32Array(new Int32Array([word]).buffer)[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
#lock() {
|
|
||||||
this.#refresh();
|
|
||||||
for (let attempt = 0; attempt < 1024; attempt++) {
|
|
||||||
const sequence = Atomics.load(this.#control, 9) >>> 0;
|
|
||||||
if (!(sequence & 1) && (Atomics.compareExchange(this.#control, 9, sequence | 0, (sequence + 1) | 0) >>> 0) === sequence)
|
|
||||||
return sequence;
|
|
||||||
}
|
|
||||||
throw new RendererError("SOA_BUSY");
|
|
||||||
}
|
|
||||||
|
|
||||||
#unlock(sequence) {
|
|
||||||
Atomics.store(this.#control, 9, (sequence + 2) | 0);
|
|
||||||
Atomics.notify(this.#control, 9);
|
|
||||||
}
|
|
||||||
|
|
||||||
read(slot) {
|
|
||||||
this.#refresh();
|
|
||||||
if (!Number.isInteger(slot) || slot < 0 || slot >= this.length) throw new RangeError("slot is outside the shared array");
|
|
||||||
const base = slot * (this.stride / 4);
|
|
||||||
for (let attempt = 0; attempt < 1024; attempt++) {
|
|
||||||
const before = Atomics.load(this.#control, 9) >>> 0;
|
|
||||||
if (before & 1) continue;
|
|
||||||
const values = Array.from({ length: this.lanes }, (_, lane) => this.#decode(Atomics.load(this.#words, base + lane)));
|
|
||||||
const after = Atomics.load(this.#control, 9) >>> 0;
|
|
||||||
if (before === after && !(after & 1)) return values;
|
|
||||||
}
|
|
||||||
throw new RendererError("SOA_BUSY");
|
|
||||||
}
|
|
||||||
|
|
||||||
write(slot, values, generation) {
|
|
||||||
if (!this.#descriptor.writable) throw new RendererError("SOA_READ_ONLY");
|
|
||||||
if (!values || values.length !== this.lanes) throw new TypeError(`values must contain ${this.lanes} lanes`);
|
|
||||||
if (this.#descriptor.generationGuard !== undefined && (!Number.isInteger(generation) || generation < 1 || generation > 0xffffffff))
|
|
||||||
throw new TypeError("generation must be a nonzero uint32");
|
|
||||||
const encoded = Array.from(values, value => this.#encode(value));
|
|
||||||
const sequence = this.#lock();
|
|
||||||
try {
|
|
||||||
if (!Number.isInteger(slot) || slot < 0 || slot >= (Atomics.load(this.#control, 6) >>> 0)) throw new RangeError("slot is outside the shared array");
|
|
||||||
const base = slot * (this.stride / 4);
|
|
||||||
encoded.forEach((word, lane) => Atomics.store(this.#words, base + lane, word));
|
|
||||||
if (this.#descriptor.generationGuard !== undefined) {
|
|
||||||
Atomics.store(this.#words, base + this.lanes, generation | 0);
|
|
||||||
Atomics.add(this.#words, base + this.lanes + 1, 1);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.#unlock(sequence);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
let canvas, context, device, surfaceFormat, memory, used = 0, loadout;
|
||||||
|
const arrays = new Map();
|
||||||
|
const align = (value, multiple) => Math.ceil(value / multiple) * multiple;
|
||||||
|
const fail = code => { throw new Error(code); };
|
||||||
|
const list = value => value === undefined ? [] : Array.isArray(value) ? value : fail("GRAPH_ARRAY");
|
||||||
|
|
||||||
|
function parse(source) {
|
||||||
|
if (typeof source !== "string") fail("GRAPH_WIRE");
|
||||||
|
const tokens = source.match(/\s*(\(|\)|"(?:\\.|[^"\\])*"|[^\s()]+)/gu) ?? [];
|
||||||
|
let at = 0;
|
||||||
|
const read = () => {
|
||||||
|
const token = tokens[at++]?.trim();
|
||||||
|
if (token === "(") {
|
||||||
|
const value = [];
|
||||||
|
while (tokens[at]?.trim() !== ")") {
|
||||||
|
if (at >= tokens.length) fail("GRAPH_WIRE");
|
||||||
|
value.push(read());
|
||||||
|
}
|
||||||
|
at++;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (!token || token === ")") fail("GRAPH_WIRE");
|
||||||
|
if (token[0] === '"') return JSON.parse(token);
|
||||||
|
if (token === "true") return true;
|
||||||
|
if (token === "false") return false;
|
||||||
|
if (token === "null") return null;
|
||||||
|
return Number.isFinite(Number(token)) ? Number(token) : token;
|
||||||
|
};
|
||||||
|
const root = read();
|
||||||
|
if (at !== tokens.length || root[0] !== "yawn-graph" || root[1] !== 1) fail("GRAPH_WIRE");
|
||||||
|
const decode = value => {
|
||||||
|
if (!Array.isArray(value)) return value;
|
||||||
|
if (value[0] === "array") return value.slice(1).map(decode);
|
||||||
|
if (value[0] === "object") return Object.fromEntries(value.slice(1).map(field => {
|
||||||
|
if (field[0] !== "field" || field.length !== 3) fail("GRAPH_WIRE");
|
||||||
|
return [field[1], decode(field[2])];
|
||||||
|
}));
|
||||||
|
fail("GRAPH_WIRE");
|
||||||
|
};
|
||||||
|
return decode(root[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function index(items, code) {
|
||||||
|
const result = new Map();
|
||||||
|
for (const item of list(items)) {
|
||||||
|
if (!item || typeof item.id !== "string" || result.has(item.id)) fail(code);
|
||||||
|
result.set(item.id, item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortPasses(passes) {
|
||||||
|
const byId = index(passes, "GRAPH_PASS");
|
||||||
|
const waiting = new Map([...byId].map(([id, pass]) => [id, new Set(list(pass.after))]));
|
||||||
|
for (const dependencies of waiting.values())
|
||||||
|
for (const dependency of dependencies) if (!byId.has(dependency)) fail("GRAPH_DEPENDENCY");
|
||||||
|
const result = [];
|
||||||
|
while (waiting.size) {
|
||||||
|
const ready = [...waiting].find(([, dependencies]) => !dependencies.size);
|
||||||
|
if (!ready) fail("GRAPH_CYCLE");
|
||||||
|
waiting.delete(ready[0]);
|
||||||
|
result.push(byId.get(ready[0]));
|
||||||
|
for (const dependencies of waiting.values()) dependencies.delete(ready[0]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bufferUsage = names => list(names).reduce((usage, name) => usage | ({
|
||||||
|
uniform: GPUBufferUsage.UNIFORM, storage: GPUBufferUsage.STORAGE,
|
||||||
|
vertex: GPUBufferUsage.VERTEX, index: GPUBufferUsage.INDEX,
|
||||||
|
indirect: GPUBufferUsage.INDIRECT, copySrc: GPUBufferUsage.COPY_SRC,
|
||||||
|
}[name] ?? fail("GRAPH_BUFFER_USAGE")), GPUBufferUsage.COPY_DST);
|
||||||
|
const textureUsage = names => list(names).reduce((usage, name) => usage | ({
|
||||||
|
render: GPUTextureUsage.RENDER_ATTACHMENT, sampled: GPUTextureUsage.TEXTURE_BINDING,
|
||||||
|
storage: GPUTextureUsage.STORAGE_BINDING, copySrc: GPUTextureUsage.COPY_SRC,
|
||||||
|
copyDst: GPUTextureUsage.COPY_DST,
|
||||||
|
}[name] ?? fail("GRAPH_TEXTURE_USAGE")), 0);
|
||||||
|
|
||||||
|
async function compile(graph) {
|
||||||
|
if (!graph || typeof graph.id !== "string") fail("GRAPH_SHAPE");
|
||||||
|
const passes = sortPasses(graph.passes);
|
||||||
|
const renderDeclarations = index(graph.pipelines?.render, "GRAPH_PIPELINE");
|
||||||
|
const computeDeclarations = index(graph.pipelines?.compute, "GRAPH_PIPELINE");
|
||||||
|
const resources = new Map(), owned = [];
|
||||||
|
try {
|
||||||
|
const usedResources = new Set(passes.flatMap(pass => [
|
||||||
|
...list(pass.bindings).map(x => x.resource),
|
||||||
|
...list(pass.color).map(x => x.resource),
|
||||||
|
...(pass.depth ? [pass.depth.resource] : []),
|
||||||
|
...list(pass.vertexBuffers).map(x => x.resource),
|
||||||
|
...(pass.indexBuffer ? [pass.indexBuffer.resource] : []),
|
||||||
|
]));
|
||||||
|
for (const declaration of list(graph.resources?.buffers)) {
|
||||||
|
if (!usedResources.has(declaration.id)) continue;
|
||||||
|
const source = arrays.get(declaration.array);
|
||||||
|
if (!source) fail("GRAPH_ARRAY_UNKNOWN");
|
||||||
|
const gpu = device.createBuffer({ size: align(source.bytes, 4), usage: bufferUsage(declaration.usage) });
|
||||||
|
resources.set(declaration.id, { kind: "buffer", gpu, source });
|
||||||
|
owned.push(gpu);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textures = index(graph.resources?.textures, "GRAPH_RESOURCE"), lifetimes = new Map(), slots = [];
|
||||||
|
passes.forEach((pass, frame) => {
|
||||||
|
for (const id of [...list(pass.bindings).map(x => x.resource), ...list(pass.color).map(x => x.resource), ...(pass.depth ? [pass.depth.resource] : [])]) {
|
||||||
|
if (!textures.has(id)) continue;
|
||||||
|
const lifetime = lifetimes.get(id) ?? [frame, frame];
|
||||||
|
lifetime[1] = frame;
|
||||||
|
lifetimes.set(id, lifetime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const declaration of textures.values()) {
|
||||||
|
const lifetime = lifetimes.get(declaration.id);
|
||||||
|
if (!lifetime) continue;
|
||||||
|
const size = declaration.size ?? ["canvas", "canvas"];
|
||||||
|
if (!Array.isArray(size) || size.length < 2 || size.length > 3) fail("GRAPH_TEXTURE_SIZE");
|
||||||
|
const descriptor = {
|
||||||
|
size: [size[0] === "canvas" ? canvas.width : size[0], size[1] === "canvas" ? canvas.height : size[1], size[2] ?? 1],
|
||||||
|
format: declaration.format,
|
||||||
|
usage: textureUsage(declaration.usage),
|
||||||
|
mipLevelCount: declaration.mipLevelCount ?? 1,
|
||||||
|
sampleCount: declaration.sampleCount ?? 1,
|
||||||
|
dimension: declaration.dimension ?? "2d",
|
||||||
|
};
|
||||||
|
const key = JSON.stringify(descriptor);
|
||||||
|
let slot = declaration.transient === false ? undefined : slots.find(value => value.key === key && value.last < lifetime[0]);
|
||||||
|
if (!slot) {
|
||||||
|
const gpu = device.createTexture(descriptor);
|
||||||
|
slot = { key, last: lifetime[1], gpu, view: gpu.createView() };
|
||||||
|
slots.push(slot);
|
||||||
|
owned.push(gpu);
|
||||||
|
} else slot.last = lifetime[1];
|
||||||
|
resources.set(declaration.id, { kind: "texture", gpu: slot.gpu, view: slot.view });
|
||||||
|
}
|
||||||
|
for (const declaration of list(graph.resources?.samplers))
|
||||||
|
if (usedResources.has(declaration.id)) resources.set(declaration.id, {
|
||||||
|
kind: "sampler", gpu: device.createSampler(declaration.descriptor),
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderPipelines = new Map(), computePipelines = new Map();
|
||||||
|
await Promise.all([...new Set(passes.filter(x => x.type === "render").map(x => x.pipeline))].map(async id => {
|
||||||
|
const declaration = renderDeclarations.get(id);
|
||||||
|
if (typeof declaration?.code !== "string") fail("GRAPH_PIPELINE");
|
||||||
|
const module = device.createShaderModule({ code: declaration.code });
|
||||||
|
renderPipelines.set(id, await device.createRenderPipelineAsync({
|
||||||
|
layout: "auto",
|
||||||
|
vertex: { module, entryPoint: declaration.vertex?.entry ?? "vertex", buffers: declaration.vertex?.buffers ?? [] },
|
||||||
|
fragment: {
|
||||||
|
module,
|
||||||
|
entryPoint: declaration.fragment?.entry ?? "fragment",
|
||||||
|
targets: list(declaration.fragment?.targets).map(target => ({
|
||||||
|
...target, format: target.format === "canvas" ? surfaceFormat : target.format,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
primitive: declaration.primitive,
|
||||||
|
depthStencil: declaration.depthStencil,
|
||||||
|
multisample: declaration.multisample,
|
||||||
|
}));
|
||||||
|
}));
|
||||||
|
await Promise.all([...new Set(passes.filter(x => x.type === "compute").map(x => x.pipeline))].map(async id => {
|
||||||
|
const declaration = computeDeclarations.get(id);
|
||||||
|
if (typeof declaration?.code !== "string") fail("GRAPH_PIPELINE");
|
||||||
|
const module = device.createShaderModule({ code: declaration.code });
|
||||||
|
computePipelines.set(id, await device.createComputePipelineAsync({
|
||||||
|
layout: "auto", compute: { module, entryPoint: declaration.entry ?? "main" },
|
||||||
|
}));
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bindGroups = (pass, pipeline) => {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const binding of list(pass.bindings)) {
|
||||||
|
const resource = resources.get(binding.resource);
|
||||||
|
if (!resource) fail("GRAPH_RESOURCE_UNKNOWN");
|
||||||
|
const value = resource.kind === "buffer"
|
||||||
|
? { buffer: resource.gpu, offset: binding.offset ?? 0, ...(binding.size ? { size: binding.size } : {}) }
|
||||||
|
: resource.kind === "texture" ? resource.view : resource.gpu;
|
||||||
|
if (!groups.has(binding.group ?? 0)) groups.set(binding.group ?? 0, []);
|
||||||
|
groups.get(binding.group ?? 0).push({ binding: binding.binding, resource: value });
|
||||||
|
}
|
||||||
|
return [...groups].map(([group, entries]) => [group, device.createBindGroup({
|
||||||
|
layout: pipeline.getBindGroupLayout(group), entries,
|
||||||
|
})]);
|
||||||
|
};
|
||||||
|
const compiled = passes.map(pass => {
|
||||||
|
const pipeline = (pass.type === "render" ? renderPipelines : computePipelines).get(pass.pipeline);
|
||||||
|
if (!pipeline) fail("GRAPH_PASS");
|
||||||
|
return { pass, pipeline, bindGroups: bindGroups(pass, pipeline) };
|
||||||
|
});
|
||||||
|
return { id: graph.id, passes: compiled, resources, owned };
|
||||||
|
} catch (error) {
|
||||||
|
owned.forEach(resource => resource.destroy?.());
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearColor = (value = [0, 0, 0, 1]) => Array.isArray(value)
|
||||||
|
? { r: value[0], g: value[1], b: value[2], a: value[3] }
|
||||||
|
: value;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!loadout) return;
|
||||||
|
for (const resource of loadout.resources.values())
|
||||||
|
if (resource.kind === "buffer") device.queue.writeBuffer(
|
||||||
|
resource.gpu, 0, new Uint8Array(memory, resource.source.offset, resource.source.bytes),
|
||||||
|
);
|
||||||
|
const encoder = device.createCommandEncoder();
|
||||||
|
for (const { pass, pipeline, bindGroups } of loadout.passes) {
|
||||||
|
if (pass.type === "compute") {
|
||||||
|
const command = encoder.beginComputePass();
|
||||||
|
command.setPipeline(pipeline);
|
||||||
|
bindGroups.forEach(([group, value]) => command.setBindGroup(group, value));
|
||||||
|
command.dispatchWorkgroups(...(pass.dispatch ?? [1, 1, 1]));
|
||||||
|
command.end();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const view = id => id === "canvas"
|
||||||
|
? context.getCurrentTexture().createView()
|
||||||
|
: loadout.resources.get(id)?.view ?? fail("GRAPH_ATTACHMENT");
|
||||||
|
const command = encoder.beginRenderPass({
|
||||||
|
colorAttachments: list(pass.color).map(attachment => ({
|
||||||
|
view: view(attachment.resource), loadOp: attachment.load ?? "clear",
|
||||||
|
storeOp: attachment.store ?? "store", clearValue: clearColor(attachment.clear),
|
||||||
|
})),
|
||||||
|
...(pass.depth ? { depthStencilAttachment: {
|
||||||
|
view: view(pass.depth.resource), depthLoadOp: pass.depth.load ?? "clear",
|
||||||
|
depthStoreOp: pass.depth.store ?? "store", depthClearValue: pass.depth.clear ?? 1,
|
||||||
|
} } : {}),
|
||||||
|
});
|
||||||
|
command.setPipeline(pipeline);
|
||||||
|
bindGroups.forEach(([group, value]) => command.setBindGroup(group, value));
|
||||||
|
list(pass.vertexBuffers).forEach(binding => command.setVertexBuffer(
|
||||||
|
binding.slot ?? 0, loadout.resources.get(binding.resource)?.gpu ?? fail("GRAPH_RESOURCE_UNKNOWN"), binding.offset ?? 0,
|
||||||
|
));
|
||||||
|
if (pass.indexBuffer) command.setIndexBuffer(
|
||||||
|
loadout.resources.get(pass.indexBuffer.resource)?.gpu ?? fail("GRAPH_RESOURCE_UNKNOWN"),
|
||||||
|
pass.indexBuffer.format ?? "uint32", pass.indexBuffer.offset ?? 0,
|
||||||
|
);
|
||||||
|
const draw = pass.draw ?? {};
|
||||||
|
if (pass.indexBuffer) command.drawIndexed(draw.indices ?? 0, draw.instances ?? 1, draw.firstIndex ?? 0, draw.baseVertex ?? 0, draw.firstInstance ?? 0);
|
||||||
|
else command.draw(draw.vertices ?? 3, draw.instances ?? 1, draw.firstVertex ?? 0, draw.firstInstance ?? 0);
|
||||||
|
command.end();
|
||||||
|
}
|
||||||
|
device.queue.submit([encoder.finish()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
try { render(); } catch (error) {
|
||||||
|
postMessage({ type: "runtime-error", error: error?.message ?? "RENDER_ERROR" });
|
||||||
|
loadout = undefined;
|
||||||
|
}
|
||||||
|
(globalThis.requestAnimationFrame ?? (callback => setTimeout(callback, 16)))(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener("message", async ({ data: message }) => {
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
if (message.type === "init") {
|
||||||
|
if (!(message.canvas instanceof OffscreenCanvas) || !Number.isInteger(message.arenaBytes) || message.arenaBytes < 64) fail("INIT");
|
||||||
|
canvas = message.canvas;
|
||||||
|
memory = new SharedArrayBuffer(align(message.arenaBytes, 64));
|
||||||
|
const adapter = await navigator.gpu?.requestAdapter();
|
||||||
|
if (!adapter) fail("WEBGPU_UNAVAILABLE");
|
||||||
|
device = await adapter.requestDevice();
|
||||||
|
context = canvas.getContext("webgpu");
|
||||||
|
surfaceFormat = navigator.gpu.getPreferredCanvasFormat();
|
||||||
|
context.configure({ device, format: surfaceFormat, alphaMode: "opaque" });
|
||||||
|
device.lost.then(() => { postMessage({ type: "runtime-error", error: "DEVICE_LOST" }); loadout = undefined; });
|
||||||
|
result = { buffer: memory };
|
||||||
|
tick();
|
||||||
|
} else if (message.type === "allocate") {
|
||||||
|
const { name, rows, stride, format } = message;
|
||||||
|
if (typeof name !== "string" || !name || arrays.has(name) || !Number.isInteger(rows) || rows < 1 ||
|
||||||
|
!Number.isInteger(stride) || stride < 16 || stride % 16 || !["f32", "u32", "i32"].includes(format)) fail("ALLOCATION");
|
||||||
|
const offset = align(used, 64), bytes = rows * stride;
|
||||||
|
if (!Number.isSafeInteger(bytes) || offset + bytes > memory.byteLength) fail("ARENA_OOM");
|
||||||
|
result = { name, rows, stride, format, offset };
|
||||||
|
arrays.set(name, { ...result, bytes });
|
||||||
|
used = offset + bytes;
|
||||||
|
} else if (message.type === "load-graph") {
|
||||||
|
const next = await compile(parse(message.serialized));
|
||||||
|
const previous = loadout;
|
||||||
|
loadout = next;
|
||||||
|
previous?.owned.forEach(resource => resource.destroy?.());
|
||||||
|
result = { id: next.id };
|
||||||
|
} else fail("MESSAGE");
|
||||||
|
postMessage({ request: message.request, result });
|
||||||
|
} catch (error) {
|
||||||
|
postMessage({ request: message.request, error: error?.message ?? "CORE_ERROR" });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "renderer"
|
|
||||||
description = "WGPU renderer core library"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib", "rlib"]
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
atomics = []
|
|
||||||
bulk-memory = []
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
wasm-bindgen = { workspace = true }
|
|
||||||
wasm-bindgen-futures = { workspace = true }
|
|
||||||
console_error_panic_hook = { workspace = true }
|
|
||||||
log = { workspace = true }
|
|
||||||
wasm-logger = { workspace = true }
|
|
||||||
web-sys = { workspace = true }
|
|
||||||
js-sys = { workspace = true }
|
|
||||||
bytemuck = { workspace = true }
|
|
||||||
wgpu = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
ultraviolet = { workspace = true }
|
|
||||||
image = { workspace = true }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
|
|
||||||
[package.metadata.wasm-pack.profile.release]
|
|
||||||
wasm-opt = false
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
#![cfg(target_arch = "wasm32")]
|
|
||||||
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::sync::mpsc;
|
|
||||||
|
|
||||||
use wasm_bindgen::prelude::*;
|
|
||||||
use wasm_bindgen_futures::spawn_local;
|
|
||||||
|
|
||||||
use crate::command_ring::CommandRing;
|
|
||||||
use crate::platform::web::worker;
|
|
||||||
use crate::renderer::ResizeMessage;
|
|
||||||
|
|
||||||
thread_local! {
|
|
||||||
static WORKER_EVENTS: RefCell<Option<mpsc::Sender<ResizeMessage>>> = const { RefCell::new(None) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deliver a low-frequency browser event to the worker-owned renderer channel.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn worker_window_event(kind: u32, values: js_sys::Float64Array) {
|
|
||||||
let values = values.to_vec();
|
|
||||||
let value = |index: usize| values.get(index).copied().unwrap_or_default();
|
|
||||||
let event = match kind {
|
|
||||||
0 => ResizeMessage {
|
|
||||||
width: value(0),
|
|
||||||
height: value(1),
|
|
||||||
scale_factor: value(2),
|
|
||||||
},
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
WORKER_EVENTS.with(|sender| {
|
|
||||||
if let Some(sender) = sender.borrow().as_ref() {
|
|
||||||
let _ = sender.send(event);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the typed renderer and return its SAB command-ring pointer.
|
|
||||||
pub fn worker_entrypoint() -> u32 {
|
|
||||||
let (sender, events) = mpsc::channel();
|
|
||||||
// The render worker owns this allocation for its entire lifetime. Publishing a
|
|
||||||
// stable address lets every connected thread use the same shared command ring.
|
|
||||||
let ring: &'static CommandRing = Box::leak(CommandRing::new());
|
|
||||||
let ring_ptr = ring.ptr();
|
|
||||||
WORKER_EVENTS.with(|worker_events| *worker_events.borrow_mut() = Some(sender));
|
|
||||||
spawn_local(async move {
|
|
||||||
worker::run_render_loop(events, ring).await;
|
|
||||||
});
|
|
||||||
ring_ptr
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
//! Versioned, fixed-slot SPSC command transport in shared WebAssembly memory.
|
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
|
||||||
|
|
||||||
pub const MAGIC: u32 = u32::from_le_bytes(*b"YAWN");
|
|
||||||
pub const VERSION: u32 = 2;
|
|
||||||
pub const CAPACITY: usize = 1024;
|
|
||||||
pub const SLOT_WORDS: usize = 40;
|
|
||||||
pub const SLOT_BYTES: usize = 160;
|
|
||||||
pub const HEADER_BYTES: usize = 64;
|
|
||||||
pub const SLOT_VERSION: u32 = 2;
|
|
||||||
const STATE_OPEN: u32 = 0;
|
|
||||||
const STATE_CORRUPT: u32 = 1;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum RingError {
|
|
||||||
Closed,
|
|
||||||
Backlog,
|
|
||||||
SlotVersion,
|
|
||||||
ZeroRequest,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(C, align(64))]
|
|
||||||
pub struct CommandRing {
|
|
||||||
header: [AtomicU32; 16],
|
|
||||||
slots: [[AtomicU32; SLOT_WORDS]; CAPACITY],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommandRing {
|
|
||||||
pub fn new() -> Box<Self> {
|
|
||||||
let ring = Box::new(Self {
|
|
||||||
header: std::array::from_fn(|_| AtomicU32::new(0)),
|
|
||||||
slots: std::array::from_fn(|_| std::array::from_fn(|_| AtomicU32::new(0))),
|
|
||||||
});
|
|
||||||
ring.header[0].store(MAGIC, Ordering::Relaxed);
|
|
||||||
ring.header[1].store(VERSION, Ordering::Relaxed);
|
|
||||||
ring.header[2].store(CAPACITY as u32, Ordering::Relaxed);
|
|
||||||
ring.header[3].store(SLOT_WORDS as u32, Ordering::Relaxed);
|
|
||||||
ring
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ptr(&self) -> u32 {
|
|
||||||
self as *const Self as usize as u32
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Consumer-only. The producer publishes word zero (slot version) last, then write_index.
|
|
||||||
pub fn drain(&self, mut visit: impl FnMut([u32; SLOT_WORDS])) -> Result<(), RingError> {
|
|
||||||
if self.header[6].load(Ordering::Acquire) != STATE_OPEN {
|
|
||||||
return Err(RingError::Closed);
|
|
||||||
}
|
|
||||||
let mut read = self.header[4].load(Ordering::Relaxed);
|
|
||||||
let write = self.header[5].load(Ordering::Acquire);
|
|
||||||
if write.wrapping_sub(read) > CAPACITY as u32 {
|
|
||||||
self.header[6].store(STATE_CORRUPT, Ordering::Release);
|
|
||||||
return Err(RingError::Backlog);
|
|
||||||
}
|
|
||||||
while read != write {
|
|
||||||
let slot = &self.slots[read as usize % CAPACITY];
|
|
||||||
let mut words = [0; SLOT_WORDS];
|
|
||||||
for (out, word) in words.iter_mut().zip(slot) {
|
|
||||||
*out = word.load(Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
let error = if words[0] != SLOT_VERSION {
|
|
||||||
Some(RingError::SlotVersion)
|
|
||||||
} else if words[2] == 0 {
|
|
||||||
Some(RingError::ZeroRequest)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
if let Some(error) = error {
|
|
||||||
self.header[6].store(STATE_CORRUPT, Ordering::Release);
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
visit(words);
|
|
||||||
read = read.wrapping_add(1);
|
|
||||||
self.header[4].store(read, Ordering::Release);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
#[test]
|
|
||||||
fn exact_layout() {
|
|
||||||
assert_eq!(std::mem::size_of::<[AtomicU32; 16]>(), HEADER_BYTES);
|
|
||||||
assert_eq!(std::mem::size_of::<[AtomicU32; SLOT_WORDS]>(), SLOT_BYTES);
|
|
||||||
assert_eq!(
|
|
||||||
std::mem::size_of::<CommandRing>(),
|
|
||||||
HEADER_BYTES + CAPACITY * SLOT_BYTES
|
|
||||||
);
|
|
||||||
assert_eq!(std::mem::align_of::<CommandRing>(), 64);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn tagged_header_and_fifo_drain() {
|
|
||||||
let ring = CommandRing::new();
|
|
||||||
assert_eq!(ring.header[0].load(Ordering::Relaxed), MAGIC);
|
|
||||||
assert_eq!(ring.header[1].load(Ordering::Relaxed), VERSION);
|
|
||||||
ring.slots[0][0].store(SLOT_VERSION, Ordering::Relaxed);
|
|
||||||
ring.slots[0][1].store(7, Ordering::Relaxed);
|
|
||||||
ring.slots[0][2].store(99, Ordering::Relaxed);
|
|
||||||
ring.header[5].store(1, Ordering::Release);
|
|
||||||
let mut seen = vec![];
|
|
||||||
ring.drain(|w| seen.push((w[1], w[2]))).unwrap();
|
|
||||||
assert_eq!(seen, [(7, 99)]);
|
|
||||||
assert_eq!(ring.header[4].load(Ordering::Acquire), 1);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn wraps_slots() {
|
|
||||||
let ring = CommandRing::new();
|
|
||||||
ring.header[4].store(CAPACITY as u32, Ordering::Relaxed);
|
|
||||||
ring.slots[0][0].store(SLOT_VERSION, Ordering::Relaxed);
|
|
||||||
ring.slots[0][1].store(3, Ordering::Relaxed);
|
|
||||||
ring.slots[0][2].store(1, Ordering::Relaxed);
|
|
||||||
ring.header[5].store(CAPACITY as u32 + 1, Ordering::Release);
|
|
||||||
let mut opcode = 0;
|
|
||||||
ring.drain(|w| opcode = w[1]).unwrap();
|
|
||||||
assert_eq!(opcode, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_slot_fails_closed() {
|
|
||||||
for (version, request, expected) in [
|
|
||||||
(SLOT_VERSION + 1, 1, RingError::SlotVersion),
|
|
||||||
(SLOT_VERSION, 0, RingError::ZeroRequest),
|
|
||||||
] {
|
|
||||||
let ring = CommandRing::new();
|
|
||||||
ring.slots[0][0].store(version, Ordering::Relaxed);
|
|
||||||
ring.slots[0][2].store(request, Ordering::Relaxed);
|
|
||||||
ring.header[5].store(1, Ordering::Release);
|
|
||||||
assert_eq!(ring.drain(|_| {}), Err(expected));
|
|
||||||
assert_eq!(ring.drain(|_| {}), Err(RingError::Closed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn full_is_valid_but_overfull_is_corrupt() {
|
|
||||||
let full = CommandRing::new();
|
|
||||||
for slot in &full.slots {
|
|
||||||
slot[0].store(SLOT_VERSION, Ordering::Relaxed);
|
|
||||||
slot[2].store(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
full.header[5].store(CAPACITY as u32, Ordering::Release);
|
|
||||||
let mut count = 0;
|
|
||||||
full.drain(|_| count += 1).unwrap();
|
|
||||||
assert_eq!(count, CAPACITY);
|
|
||||||
|
|
||||||
let overfull = CommandRing::new();
|
|
||||||
overfull.header[5].store(CAPACITY as u32 + 1, Ordering::Release);
|
|
||||||
assert_eq!(overfull.drain(|_| {}), Err(RingError::Backlog));
|
|
||||||
assert_eq!(overfull.drain(|_| {}), Err(RingError::Closed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
pub mod app_setup;
|
|
||||||
pub mod command_ring;
|
|
||||||
pub mod platform;
|
|
||||||
pub mod render_data;
|
|
||||||
pub mod render_graph;
|
|
||||||
pub mod renderer;
|
|
||||||
pub mod shared_snapshot;
|
|
||||||
pub mod shared_soa;
|
|
||||||
|
|
||||||
/// Start the core renderer inside its owning worker.
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
|
||||||
pub fn worker_main() -> u32 {
|
|
||||||
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
|
|
||||||
wasm_logger::init(wasm_logger::Config::default());
|
|
||||||
app_setup::worker_entrypoint()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return this worker's shared WebAssembly memory to messaging clients.
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
|
||||||
pub fn worker_memory() -> wasm_bindgen::JsValue {
|
|
||||||
wasm_bindgen::memory()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
thread_local! { static PAYLOADS: std::cell::RefCell<std::collections::HashMap<u32, Vec<u8>>> = Default::default(); }
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
|
||||||
pub fn stage_payload(id: u32, bytes: js_sys::Uint8Array) {
|
|
||||||
PAYLOADS.with(|payloads| {
|
|
||||||
payloads.borrow_mut().insert(id, bytes.to_vec());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
|
||||||
pub fn discard_payload(id: u32) {
|
|
||||||
PAYLOADS.with(|payloads| {
|
|
||||||
payloads.borrow_mut().remove(&id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
|
||||||
pub fn clear_payloads() {
|
|
||||||
PAYLOADS.with(|payloads| payloads.borrow_mut().clear());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub(crate) fn take_payload(id: u32) -> Option<Vec<u8>> {
|
|
||||||
PAYLOADS.with(|payloads| payloads.borrow_mut().remove(&id))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub(crate) fn take_payload(_id: u32) -> Option<Vec<u8>> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub mod web;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
pub mod worker;
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
// The render worker owns its only WASM and WebGPU runtime.
|
|
||||||
import initWasm, {
|
|
||||||
clear_payloads,
|
|
||||||
discard_payload,
|
|
||||||
stage_payload,
|
|
||||||
worker_main,
|
|
||||||
worker_memory,
|
|
||||||
worker_window_event,
|
|
||||||
} from "/renderer/pkg/renderer.js";
|
|
||||||
|
|
||||||
function listenerReady() {
|
|
||||||
if (state !== "waiting-listener") return;
|
|
||||||
state = "replaying";
|
|
||||||
for (const queued of pending.splice(0)) route(queued);
|
|
||||||
state = "ready";
|
|
||||||
}
|
|
||||||
|
|
||||||
let api;
|
|
||||||
let state = "uninitialized";
|
|
||||||
const pending = [];
|
|
||||||
|
|
||||||
// This listener is never replaced: canvas and payload transfers that race WASM
|
|
||||||
// initialization remain ordered and are replayed after init.
|
|
||||||
addEventListener("message", async (event) => {
|
|
||||||
const message = event.data;
|
|
||||||
if (message?.type !== "init") {
|
|
||||||
if (state !== "ready") pending.push(message);
|
|
||||||
else route(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state !== "uninitialized") return;
|
|
||||||
state = "initializing";
|
|
||||||
const { canvas } = message;
|
|
||||||
|
|
||||||
// The renderer worker exclusively owns the one WASM instance. Other threads
|
|
||||||
// receive only its shared memory and mutate the published SAB layouts.
|
|
||||||
try {
|
|
||||||
api = await initWasm();
|
|
||||||
} catch (error) {
|
|
||||||
fatal("WORKER_INIT_FAILED", error?.stack || String(error));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state = "waiting-listener";
|
|
||||||
pending.push({ type: "canvas", canvas });
|
|
||||||
try {
|
|
||||||
const ringPtr = worker_main();
|
|
||||||
postMessage({ type: "bootstrap", memory: worker_memory(), ringPtr });
|
|
||||||
setTimeout(listenerReady, 0);
|
|
||||||
} catch (error) {
|
|
||||||
fatal("WORKER_ENTRY_FAILED", error?.stack || String(error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function route(message) {
|
|
||||||
if (message?.type === "canvas") {
|
|
||||||
dispatchEvent(new MessageEvent("renderer-canvas", { data: message.canvas }));
|
|
||||||
} else if (message?.type === "payload") {
|
|
||||||
stage_payload(message.id, new Uint8Array(message.buffer));
|
|
||||||
postMessage({ type: "payload-ready", id: message.id });
|
|
||||||
} else if (message?.type === "payload-release") {
|
|
||||||
discard_payload(message.id);
|
|
||||||
} else if (message?.type === "window-event") {
|
|
||||||
worker_window_event(message.kind, message.values);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fatal(code, message) {
|
|
||||||
state = "failed";
|
|
||||||
pending.length = 0;
|
|
||||||
try { clear_payloads?.(); } catch { /* best effort during a fatal failure */ }
|
|
||||||
postMessage({type:"fatal",code,message});
|
|
||||||
}
|
|
||||||
addEventListener("error", event => fatal("WORKER_RUNTIME_ERROR", event.error?.stack || `${event.message} (${event.filename}:${event.lineno}:${event.colno})`));
|
|
||||||
addEventListener("unhandledrejection", event => fatal("WORKER_UNHANDLED_REJECTION",String(event.reason)));
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
use crate::command_ring::CommandRing;
|
|
||||||
use crate::renderer::ResizeMessage;
|
|
||||||
use log::info;
|
|
||||||
use std::sync::mpsc::Receiver;
|
|
||||||
use std::{cell::RefCell, rc::Rc};
|
|
||||||
use wasm_bindgen::{prelude::*, JsValue};
|
|
||||||
use wasm_bindgen_futures::JsFuture;
|
|
||||||
use web_sys::MessageEvent;
|
|
||||||
|
|
||||||
pub async fn run_render_loop(events_chan: Receiver<ResizeMessage>, ring: &'static CommandRing) {
|
|
||||||
use crate::renderer::Renderer;
|
|
||||||
|
|
||||||
let canvas = wait_for_canvas_transfer().await;
|
|
||||||
|
|
||||||
let renderer = Rc::new(RefCell::new(Renderer::new(canvas, events_chan).await));
|
|
||||||
renderer.borrow_mut().command_ring = Some(ring);
|
|
||||||
Renderer::run_render_loop(renderer);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn wait_for_canvas_transfer() -> web_sys::OffscreenCanvas {
|
|
||||||
let global = js_sys::global().unchecked_into::<web_sys::DedicatedWorkerGlobalScope>();
|
|
||||||
|
|
||||||
let promise = js_sys::Promise::new(&mut |resolve, _reject| {
|
|
||||||
let handler = Closure::once(move |event: MessageEvent| {
|
|
||||||
let data = event.data();
|
|
||||||
|
|
||||||
info!("data received: {:?}", data);
|
|
||||||
|
|
||||||
// Check if the received data is an OffscreenCanvas directly
|
|
||||||
if data.is_instance_of::<web_sys::OffscreenCanvas>() {
|
|
||||||
resolve
|
|
||||||
.call1(&JsValue::NULL, &data)
|
|
||||||
.expect("resolve failed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
global
|
|
||||||
.add_event_listener_with_callback("renderer-canvas", handler.as_ref().unchecked_ref())
|
|
||||||
.unwrap();
|
|
||||||
handler.forget();
|
|
||||||
});
|
|
||||||
|
|
||||||
let canvas: web_sys::OffscreenCanvas = JsFuture::from(promise)
|
|
||||||
.await
|
|
||||||
.expect("promise rejected")
|
|
||||||
.unchecked_into();
|
|
||||||
|
|
||||||
info!("received canvas: {:?}", canvas);
|
|
||||||
canvas
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
use std::f32::consts::PI;
|
|
||||||
|
|
||||||
use ultraviolet::{projection, Mat4, Vec3};
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use wgpu::util::DeviceExt;
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use crate::renderer::frame_data::UniformResource;
|
|
||||||
|
|
||||||
/// A camera matrix cannot produce a safe, meaningful frustum.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
|
||||||
pub enum FrustumError {
|
|
||||||
#[error("frustum plane {plane} contains a non-finite component")]
|
|
||||||
NonFinite { plane: usize },
|
|
||||||
#[error("frustum plane {plane} has a near-degenerate normal")]
|
|
||||||
Degenerate { plane: usize },
|
|
||||||
}
|
|
||||||
|
|
||||||
const MIN_DISTANCE: f32 = 0.1;
|
|
||||||
|
|
||||||
/// SIMD-width shared camera row: eye, target, up, then projection parameters.
|
|
||||||
pub type SharedCameraState = [f32; 16];
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct Camera {
|
|
||||||
// Hot data - cached computed matrix (64 bytes, 1 cache line)
|
|
||||||
pub view_proj: [[f32; 4]; 4],
|
|
||||||
|
|
||||||
// Warm data - frequently accessed vectors (36 bytes)
|
|
||||||
position: Vec3,
|
|
||||||
target: Vec3,
|
|
||||||
up: Vec3,
|
|
||||||
|
|
||||||
// Cold data - projection parameters (16 bytes)
|
|
||||||
fov: f32,
|
|
||||||
aspect_ratio: f32,
|
|
||||||
z_near: f32,
|
|
||||||
z_far: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)]
|
|
||||||
pub struct CameraUniform {
|
|
||||||
view_proj: [[f32; 4]; 4],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Camera {
|
|
||||||
pub fn frustum_planes(&self) -> Result<[[f32; 4]; 6], FrustumError> {
|
|
||||||
extract_frustum_planes(self.view_proj)
|
|
||||||
}
|
|
||||||
pub fn new(aspect_ratio: f32) -> Self {
|
|
||||||
let mut camera = Camera {
|
|
||||||
view_proj: [[0.0; 4]; 4],
|
|
||||||
position: Vec3::new(0.0, 0.5, 3.0),
|
|
||||||
target: Vec3::new(0.0, 0.0, 0.0),
|
|
||||||
up: Vec3::unit_y(),
|
|
||||||
fov: PI / 3.0,
|
|
||||||
aspect_ratio,
|
|
||||||
z_near: 0.1,
|
|
||||||
z_far: 100000.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
camera.compute_view_proj_mat();
|
|
||||||
|
|
||||||
camera
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn compute_view_proj_mat(&mut self) {
|
|
||||||
let view = Mat4::look_at(self.position, self.target, self.up);
|
|
||||||
let proj = projection::rh_yup::perspective_wgpu_dx(
|
|
||||||
self.fov,
|
|
||||||
self.aspect_ratio,
|
|
||||||
self.z_near,
|
|
||||||
self.z_far,
|
|
||||||
);
|
|
||||||
self.view_proj = (proj * view).into();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn position(&self) -> Vec3 {
|
|
||||||
self.position
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_aspect_ratio(&mut self, aspect_ratio: f32) {
|
|
||||||
self.aspect_ratio = aspect_ratio;
|
|
||||||
self.compute_view_proj_mat();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snapshot the canonical 64-byte shared row.
|
|
||||||
pub fn shared_state(&self) -> SharedCameraState {
|
|
||||||
[
|
|
||||||
self.position.x,
|
|
||||||
self.position.y,
|
|
||||||
self.position.z,
|
|
||||||
1.0,
|
|
||||||
self.target.x,
|
|
||||||
self.target.y,
|
|
||||||
self.target.z,
|
|
||||||
1.0,
|
|
||||||
self.up.x,
|
|
||||||
self.up.y,
|
|
||||||
self.up.z,
|
|
||||||
0.0,
|
|
||||||
self.fov,
|
|
||||||
self.aspect_ratio,
|
|
||||||
self.z_near,
|
|
||||||
self.z_far,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply a complete shared row, rejecting malformed external writes.
|
|
||||||
pub fn apply_shared_state(&mut self, state: SharedCameraState) -> bool {
|
|
||||||
if !state.iter().all(|value| value.is_finite())
|
|
||||||
|| !(0.0..PI).contains(&state[12])
|
|
||||||
|| state[13] <= 0.0
|
|
||||||
|| state[14] <= 0.0
|
|
||||||
|| state[15] <= state[14]
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let position = Vec3::new(state[0], state[1], state[2]);
|
|
||||||
let target = Vec3::new(state[4], state[5], state[6]);
|
|
||||||
let up = Vec3::new(state[8], state[9], state[10]);
|
|
||||||
let forward = target - position;
|
|
||||||
if forward.mag_sq() < MIN_DISTANCE * MIN_DISTANCE
|
|
||||||
|| up.mag_sq() <= f32::EPSILON
|
|
||||||
|| forward.cross(up).mag_sq() <= f32::EPSILON
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
self.position = position;
|
|
||||||
self.target = target;
|
|
||||||
self.up = up.normalized();
|
|
||||||
self.fov = state[12];
|
|
||||||
self.aspect_ratio = state[13];
|
|
||||||
self.z_near = state[14];
|
|
||||||
self.z_far = state[15];
|
|
||||||
self.compute_view_proj_mat();
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub fn create_uniform_resource(&self, device: &wgpu::Device) -> UniformResource {
|
|
||||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
|
||||||
label: "camera uniform buffer".into(),
|
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
|
||||||
contents: bytemuck::cast_slice(&[self.view_proj]),
|
|
||||||
});
|
|
||||||
|
|
||||||
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
|
||||||
label: Some("Camera bind group layout"),
|
|
||||||
entries: &[wgpu::BindGroupLayoutEntry {
|
|
||||||
binding: 0,
|
|
||||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
|
||||||
ty: wgpu::BindingType::Buffer {
|
|
||||||
ty: wgpu::BufferBindingType::Uniform,
|
|
||||||
has_dynamic_offset: false,
|
|
||||||
min_binding_size: None,
|
|
||||||
},
|
|
||||||
count: None,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
|
|
||||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
||||||
label: Some("Camera bind group"),
|
|
||||||
layout: &bind_group_layout,
|
|
||||||
entries: &[wgpu::BindGroupEntry {
|
|
||||||
binding: 0,
|
|
||||||
resource: buffer.as_entire_binding(),
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
|
|
||||||
UniformResource {
|
|
||||||
buffer,
|
|
||||||
bind_group,
|
|
||||||
bind_group_layout,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts inward-facing normalized WebGPU clip-space planes (zero-to-one depth).
|
|
||||||
pub fn extract_frustum_planes(m: [[f32; 4]; 4]) -> Result<[[f32; 4]; 6], FrustumError> {
|
|
||||||
let row = |r: usize| [m[0][r], m[1][r], m[2][r], m[3][r]];
|
|
||||||
let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]];
|
|
||||||
let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]];
|
|
||||||
let r0 = row(0);
|
|
||||||
let r1 = row(1);
|
|
||||||
let r2 = row(2);
|
|
||||||
let r3 = row(3);
|
|
||||||
let mut planes = [
|
|
||||||
add(r3, r0),
|
|
||||||
sub(r3, r0),
|
|
||||||
add(r3, r1),
|
|
||||||
sub(r3, r1),
|
|
||||||
r2,
|
|
||||||
sub(r3, r2),
|
|
||||||
];
|
|
||||||
for (plane, p) in planes.iter_mut().enumerate() {
|
|
||||||
if !p.iter().all(|component| component.is_finite()) {
|
|
||||||
return Err(FrustumError::NonFinite { plane });
|
|
||||||
}
|
|
||||||
// Scale first: directly squaring very large/small coefficients can overflow or
|
|
||||||
// underflow even though the plane itself is normalizable.
|
|
||||||
let scale = p[0].abs().max(p[1].abs()).max(p[2].abs());
|
|
||||||
if scale < f32::MIN_POSITIVE {
|
|
||||||
return Err(FrustumError::Degenerate { plane });
|
|
||||||
}
|
|
||||||
let scaled = [p[0] / scale, p[1] / scale, p[2] / scale];
|
|
||||||
let length = (scaled[0] * scaled[0] + scaled[1] * scaled[1] + scaled[2] * scaled[2]).sqrt();
|
|
||||||
for v in p {
|
|
||||||
*v = (*v / scale) / length;
|
|
||||||
if !v.is_finite() {
|
|
||||||
return Err(FrustumError::NonFinite { plane });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(planes)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn frustum_extraction_rejects_nonfinite_and_degenerate_planes() {
|
|
||||||
let mut nonfinite = Camera::new(1.0).view_proj;
|
|
||||||
nonfinite[0][0] = f32::NAN;
|
|
||||||
assert!(matches!(
|
|
||||||
extract_frustum_planes(nonfinite),
|
|
||||||
Err(FrustumError::NonFinite { .. })
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
extract_frustum_planes([[0.0; 4]; 4]),
|
|
||||||
Err(FrustumError::Degenerate { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn frustum_extraction_normalizes_without_overflow() {
|
|
||||||
let mut matrix = Camera::new(1.0).view_proj;
|
|
||||||
for value in matrix.iter_mut().flatten() {
|
|
||||||
*value *= 1.0e20;
|
|
||||||
}
|
|
||||||
let planes = extract_frustum_planes(matrix).unwrap();
|
|
||||||
for plane in planes {
|
|
||||||
let length = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
|
|
||||||
assert!((length - 1.0).abs() < 1.0e-5);
|
|
||||||
assert!(plane.iter().all(|value| value.is_finite()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shared_state_round_trips_and_rejects_invalid_projection() {
|
|
||||||
let mut camera = Camera::new(1.0);
|
|
||||||
let state = [
|
|
||||||
2.0,
|
|
||||||
3.0,
|
|
||||||
10.0,
|
|
||||||
1.0,
|
|
||||||
1.0,
|
|
||||||
-1.0,
|
|
||||||
0.5,
|
|
||||||
1.0,
|
|
||||||
0.0,
|
|
||||||
1.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
PI / 4.0,
|
|
||||||
16.0 / 9.0,
|
|
||||||
0.25,
|
|
||||||
500.0,
|
|
||||||
];
|
|
||||||
assert!(camera.apply_shared_state(state));
|
|
||||||
assert_eq!(camera.shared_state(), state);
|
|
||||||
assert!(camera
|
|
||||||
.view_proj
|
|
||||||
.iter()
|
|
||||||
.flatten()
|
|
||||||
.all(|component| component.is_finite()));
|
|
||||||
|
|
||||||
let mut invalid = state;
|
|
||||||
invalid[15] = invalid[14];
|
|
||||||
assert!(!camera.apply_shared_state(invalid));
|
|
||||||
assert_eq!(camera.shared_state(), state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
use bytemuck::{Pod, Zeroable};
|
|
||||||
|
|
||||||
macro_rules! handle {
|
|
||||||
($name:ident) => {
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Pod, Zeroable)]
|
|
||||||
pub struct $name {
|
|
||||||
slot: u32,
|
|
||||||
generation: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl $name {
|
|
||||||
pub const fn from_parts(slot: u32, generation: u32) -> Self {
|
|
||||||
Self { slot, generation }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn slot(self) -> u32 {
|
|
||||||
self.slot
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn generation(self) -> u32 {
|
|
||||||
self.generation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
handle!(MeshHandle);
|
|
||||||
handle!(InstanceHandle);
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub(super) enum SlotState {
|
|
||||||
Occupied,
|
|
||||||
Vacant { next: Option<u32> },
|
|
||||||
Retired,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
pub(super) struct PreparedSlot {
|
|
||||||
pub slot: u32,
|
|
||||||
pub generation: u32,
|
|
||||||
reused_next: Option<u32>,
|
|
||||||
append: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct SlotTable {
|
|
||||||
pub(super) generations: Vec<u32>,
|
|
||||||
pub(super) states: Vec<SlotState>,
|
|
||||||
free_head: Option<u32>,
|
|
||||||
live_count: u32,
|
|
||||||
logical_capacity: u32,
|
|
||||||
pub(super) max_capacity: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SlotTable {
|
|
||||||
pub fn new(
|
|
||||||
initial: u32,
|
|
||||||
max: Option<u32>,
|
|
||||||
resource: &'static str,
|
|
||||||
) -> Result<Self, crate::render_data::RenderDataError> {
|
|
||||||
let mut table = Self {
|
|
||||||
generations: Vec::new(),
|
|
||||||
states: Vec::new(),
|
|
||||||
free_head: None,
|
|
||||||
live_count: 0,
|
|
||||||
logical_capacity: 0,
|
|
||||||
max_capacity: max,
|
|
||||||
};
|
|
||||||
table.reserve_for_len(initial, resource)?;
|
|
||||||
Ok(table)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn live_count(&self) -> u32 {
|
|
||||||
self.live_count
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn logical_capacity(&self) -> u32 {
|
|
||||||
self.logical_capacity
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn max_capacity(&self) -> Option<u32> {
|
|
||||||
self.max_capacity
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn required_len_for_prepare(&self) -> Result<u32, crate::render_data::RenderDataError> {
|
|
||||||
if self.free_head.is_some() {
|
|
||||||
u32::try_from(self.generations.len()).map_err(|_| {
|
|
||||||
crate::render_data::RenderDataError::CapacityOverflow { resource: "slots" }
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
let len = u32::try_from(self.generations.len()).map_err(|_| {
|
|
||||||
crate::render_data::RenderDataError::CapacityOverflow { resource: "slots" }
|
|
||||||
})?;
|
|
||||||
len.checked_add(1)
|
|
||||||
.ok_or(crate::render_data::RenderDataError::CapacityOverflow { resource: "slots" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reserve_for_len(
|
|
||||||
&mut self,
|
|
||||||
required: u32,
|
|
||||||
resource: &'static str,
|
|
||||||
) -> Result<(), crate::render_data::RenderDataError> {
|
|
||||||
let target = crate::render_data::next_capacity(
|
|
||||||
self.logical_capacity,
|
|
||||||
required,
|
|
||||||
self.max_capacity,
|
|
||||||
resource,
|
|
||||||
)?;
|
|
||||||
crate::render_data::reserve_vec(&mut self.generations, target, resource)?;
|
|
||||||
crate::render_data::reserve_vec(&mut self.states, target, resource)?;
|
|
||||||
self.logical_capacity = target;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prepare(&self) -> Result<PreparedSlot, crate::render_data::RenderDataError> {
|
|
||||||
if let Some(slot) = self.free_head {
|
|
||||||
let index = slot as usize;
|
|
||||||
let SlotState::Vacant { next } = self.states[index] else {
|
|
||||||
unreachable!("free list points to a non-vacant slot")
|
|
||||||
};
|
|
||||||
Ok(PreparedSlot {
|
|
||||||
slot,
|
|
||||||
generation: self.generations[index],
|
|
||||||
reused_next: next,
|
|
||||||
append: false,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
let slot = u32::try_from(self.generations.len()).map_err(|_| {
|
|
||||||
crate::render_data::RenderDataError::CapacityOverflow { resource: "slots" }
|
|
||||||
})?;
|
|
||||||
Ok(PreparedSlot {
|
|
||||||
slot,
|
|
||||||
generation: 1,
|
|
||||||
reused_next: None,
|
|
||||||
append: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn commit(&mut self, prepared: PreparedSlot) {
|
|
||||||
if prepared.append {
|
|
||||||
self.generations.push(prepared.generation);
|
|
||||||
self.states.push(SlotState::Occupied);
|
|
||||||
} else {
|
|
||||||
self.free_head = prepared.reused_next;
|
|
||||||
self.states[prepared.slot as usize] = SlotState::Occupied;
|
|
||||||
}
|
|
||||||
self.live_count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn contains(&self, slot: u32, generation: u32) -> bool {
|
|
||||||
let index = slot as usize;
|
|
||||||
self.generations.get(index) == Some(&generation)
|
|
||||||
&& matches!(self.states.get(index), Some(SlotState::Occupied))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(&mut self, slot: u32, generation: u32) -> bool {
|
|
||||||
if !self.contains(slot, generation) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let index = slot as usize;
|
|
||||||
self.live_count -= 1;
|
|
||||||
if generation == u32::MAX {
|
|
||||||
self.states[index] = SlotState::Retired;
|
|
||||||
} else {
|
|
||||||
self.generations[index] = generation + 1;
|
|
||||||
self.states[index] = SlotState::Vacant {
|
|
||||||
next: self.free_head,
|
|
||||||
};
|
|
||||||
self.free_head = Some(slot);
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.free_head = None;
|
|
||||||
self.live_count = 0;
|
|
||||||
for index in (0..self.states.len()).rev() {
|
|
||||||
match self.states[index] {
|
|
||||||
SlotState::Occupied if self.generations[index] == u32::MAX => {
|
|
||||||
self.states[index] = SlotState::Retired;
|
|
||||||
}
|
|
||||||
SlotState::Occupied => {
|
|
||||||
self.generations[index] += 1;
|
|
||||||
self.states[index] = SlotState::Vacant {
|
|
||||||
next: self.free_head,
|
|
||||||
};
|
|
||||||
self.free_head = Some(
|
|
||||||
u32::try_from(index).expect("slot table length was checked before append"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
SlotState::Vacant { .. } => {
|
|
||||||
self.states[index] = SlotState::Vacant {
|
|
||||||
next: self.free_head,
|
|
||||||
};
|
|
||||||
self.free_head = Some(
|
|
||||||
u32::try_from(index).expect("slot table length was checked before append"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
SlotState::Retired => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn seed_successor(&mut self, predecessor: &Self) {
|
|
||||||
self.generations.clear();
|
|
||||||
self.states.clear();
|
|
||||||
self.free_head = None;
|
|
||||||
self.live_count = 0;
|
|
||||||
for generation in predecessor.generations.iter().copied() {
|
|
||||||
let generation = generation.saturating_add(1);
|
|
||||||
self.generations.push(generation);
|
|
||||||
if generation == u32::MAX {
|
|
||||||
self.states.push(SlotState::Retired);
|
|
||||||
} else {
|
|
||||||
self.states.push(SlotState::Vacant {
|
|
||||||
next: self.free_head,
|
|
||||||
});
|
|
||||||
self.free_head = Some((self.states.len() - 1) as u32);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn occupied(&self) -> impl Iterator<Item = (u32, u32)> + '_ {
|
|
||||||
self.states.iter().enumerate().filter_map(|(index, state)| {
|
|
||||||
matches!(state, SlotState::Occupied).then(|| {
|
|
||||||
(
|
|
||||||
u32::try_from(index).expect("slot table length was checked before append"),
|
|
||||||
self.generations[index],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn force_generation(&mut self, slot: u32, generation: u32) {
|
|
||||||
self.generations[slot as usize] = generation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,85 +0,0 @@
|
|||||||
use std::ops::Range;
|
|
||||||
|
|
||||||
use super::RenderDataError;
|
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
|
||||||
pub(super) struct RangeAllocator {
|
|
||||||
free: Vec<Range<u32>>,
|
|
||||||
pub(super) high_water: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RangeAllocator {
|
|
||||||
pub fn allocate(&mut self, count: u32) -> Result<Range<u32>, RenderDataError> {
|
|
||||||
if count == 0 {
|
|
||||||
return Err(RenderDataError::EmptyRange);
|
|
||||||
}
|
|
||||||
if let Some(index) = self
|
|
||||||
.free
|
|
||||||
.iter()
|
|
||||||
.position(|range| range.end - range.start >= count)
|
|
||||||
{
|
|
||||||
let start = self.free[index].start;
|
|
||||||
let end = start
|
|
||||||
.checked_add(count)
|
|
||||||
.ok_or(RenderDataError::RangeOverflow)?;
|
|
||||||
self.free[index].start = end;
|
|
||||||
if self.free[index].is_empty() {
|
|
||||||
self.free.remove(index);
|
|
||||||
}
|
|
||||||
return Ok(start..end);
|
|
||||||
}
|
|
||||||
let end = self
|
|
||||||
.high_water
|
|
||||||
.checked_add(count)
|
|
||||||
.ok_or(RenderDataError::RangeOverflow)?;
|
|
||||||
let range = self.high_water..end;
|
|
||||||
self.high_water = end;
|
|
||||||
Ok(range)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn free(&mut self, range: Range<u32>) -> Result<u32, RenderDataError> {
|
|
||||||
if range.start >= range.end {
|
|
||||||
return Err(RenderDataError::EmptyRange);
|
|
||||||
}
|
|
||||||
if range.end > self.high_water {
|
|
||||||
return Err(RenderDataError::RangeOutOfBounds);
|
|
||||||
}
|
|
||||||
let index = self
|
|
||||||
.free
|
|
||||||
.partition_point(|candidate| candidate.start < range.start);
|
|
||||||
if index > 0 && self.free[index - 1].end > range.start
|
|
||||||
|| index < self.free.len() && self.free[index].start < range.end
|
|
||||||
{
|
|
||||||
return Err(RenderDataError::RangeOverlap);
|
|
||||||
}
|
|
||||||
|
|
||||||
let joins_left = index > 0 && self.free[index - 1].end == range.start;
|
|
||||||
let joins_right = index < self.free.len() && self.free[index].start == range.end;
|
|
||||||
match (joins_left, joins_right) {
|
|
||||||
(true, true) => {
|
|
||||||
let right_end = self.free.remove(index).end;
|
|
||||||
self.free[index - 1].end = right_end;
|
|
||||||
}
|
|
||||||
(true, false) => self.free[index - 1].end = range.end,
|
|
||||||
(false, true) => self.free[index].start = range.start,
|
|
||||||
(false, false) => self.free.insert(index, range),
|
|
||||||
}
|
|
||||||
while self
|
|
||||||
.free
|
|
||||||
.last()
|
|
||||||
.is_some_and(|range| range.end == self.high_water)
|
|
||||||
{
|
|
||||||
self.high_water = self.free.pop().unwrap().start;
|
|
||||||
}
|
|
||||||
Ok(self.high_water)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn high_water(&self) -> u32 {
|
|
||||||
self.high_water
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.free.clear();
|
|
||||||
self.high_water = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,577 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
use crate::render_data::handle::SlotState;
|
|
||||||
|
|
||||||
const POSITIONS: [[f32; 3]; 3] = [[-1.0, 2.0, 3.0], [4.0, -2.0, 1.0], [0.0, 1.0, -3.0]];
|
|
||||||
const NORMALS: [[f32; 3]; 3] = [[0.0, 1.0, 0.0]; 3];
|
|
||||||
const TANGENTS: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 1.0]; 3];
|
|
||||||
const UVS: [[f32; 2]; 3] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
|
|
||||||
const INDICES: [u32; 3] = [0, 1, 2];
|
|
||||||
|
|
||||||
fn info() -> MeshCreateInfo<'static> {
|
|
||||||
MeshCreateInfo {
|
|
||||||
positions: &POSITIONS,
|
|
||||||
normals: &NORMALS,
|
|
||||||
tangents: &TANGENTS,
|
|
||||||
uvs: &UVS,
|
|
||||||
indices: &INDICES,
|
|
||||||
material: MaterialKey::new(11),
|
|
||||||
default_instance_type: InstanceType {
|
|
||||||
words: [3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
||||||
},
|
|
||||||
default_transform: IDENTITY_MODEL_TRANSFORM,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn data() -> RenderData {
|
|
||||||
RenderData::new(RenderDataConfig {
|
|
||||||
initial_vertices: 0,
|
|
||||||
initial_indices: 0,
|
|
||||||
initial_meshes: 0,
|
|
||||||
initial_instances: 0,
|
|
||||||
..RenderDataConfig::default()
|
|
||||||
})
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn affine_world_bounds_cover_translation_scale_shear_and_planes() {
|
|
||||||
let local = Aabb {
|
|
||||||
min: [-1.0, -2.0, 0.0],
|
|
||||||
max: [1.0, 2.0, 0.0],
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
affine_world_aabb(local, IDENTITY_MODEL_TRANSFORM),
|
|
||||||
Ok(local)
|
|
||||||
);
|
|
||||||
|
|
||||||
let model = [
|
|
||||||
[-2.0, 0.0, 0.0, 0.0],
|
|
||||||
[0.5, 3.0, 0.0, 0.0],
|
|
||||||
[0.0, 0.0, 1.0, 0.0],
|
|
||||||
[10.0, -4.0, 2.0, 1.0],
|
|
||||||
];
|
|
||||||
assert_eq!(
|
|
||||||
affine_world_aabb(local, model),
|
|
||||||
Ok(Aabb {
|
|
||||||
min: [7.0, -10.0, 2.0],
|
|
||||||
max: [13.0, 2.0, 2.0],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn world_bounds_reject_projective_and_overflowing_transforms() {
|
|
||||||
let local = Aabb {
|
|
||||||
min: [-1.0; 3],
|
|
||||||
max: [1.0; 3],
|
|
||||||
};
|
|
||||||
let mut projective = IDENTITY_MODEL_TRANSFORM;
|
|
||||||
projective[0][3] = 0.5;
|
|
||||||
assert_eq!(
|
|
||||||
affine_world_aabb(local, projective),
|
|
||||||
Err(RenderDataError::InvalidTransform)
|
|
||||||
);
|
|
||||||
let mut overflowing = IDENTITY_MODEL_TRANSFORM;
|
|
||||||
overflowing[0][0] = f32::MAX;
|
|
||||||
overflowing[1][0] = f32::MAX;
|
|
||||||
assert_eq!(
|
|
||||||
affine_world_aabb(local, overflowing),
|
|
||||||
Err(RenderDataError::InvalidTransform)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_instance_is_protected_and_preserves_its_type() {
|
|
||||||
let mut data = data();
|
|
||||||
let created = data.create_mesh(info()).unwrap();
|
|
||||||
assert!(data.instance(created.default_instance).unwrap().is_default);
|
|
||||||
assert_eq!(
|
|
||||||
data.mesh(created.mesh).unwrap().default_instance_type.words[0],
|
|
||||||
3
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
data.mesh(created.mesh).unwrap().material,
|
|
||||||
MaterialKey::new(11)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
data.instance(created.default_instance)
|
|
||||||
.unwrap()
|
|
||||||
.instance_type,
|
|
||||||
info().default_instance_type
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
data.destroy_instance(created.default_instance),
|
|
||||||
Err(RenderDataError::CannotDestroyDefaultInstance)
|
|
||||||
);
|
|
||||||
let replacement = InstanceType {
|
|
||||||
words: [
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
4,
|
|
||||||
8,
|
|
||||||
0x8000_0000,
|
|
||||||
u32::MAX,
|
|
||||||
17,
|
|
||||||
31,
|
|
||||||
63,
|
|
||||||
127,
|
|
||||||
255,
|
|
||||||
511,
|
|
||||||
1023,
|
|
||||||
2047,
|
|
||||||
4095,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
data.set_instance_type(created.default_instance, replacement)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
data.mesh(created.mesh).unwrap().default_instance_type,
|
|
||||||
info().default_instance_type
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
data.instance(created.default_instance)
|
|
||||||
.unwrap()
|
|
||||||
.instance_type,
|
|
||||||
replacement
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn instance_type_default_is_exactly_zero() {
|
|
||||||
assert_eq!(InstanceType::default(), InstanceType::ZERO);
|
|
||||||
assert_eq!(InstanceType::default().words, [0; 16]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stale_mesh_and_instance_handles_are_rejected_after_reuse() {
|
|
||||||
let mut data = data();
|
|
||||||
let first = data.create_mesh(info()).unwrap();
|
|
||||||
let old_instance = data
|
|
||||||
.create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
|
|
||||||
.unwrap();
|
|
||||||
data.destroy_instance(old_instance).unwrap();
|
|
||||||
let replacement = data
|
|
||||||
.create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(old_instance.slot(), replacement.slot());
|
|
||||||
assert_ne!(old_instance.generation(), replacement.generation());
|
|
||||||
assert!(data.instance(old_instance).is_none());
|
|
||||||
data.destroy_mesh(first.mesh).unwrap();
|
|
||||||
let second = data.create_mesh(info()).unwrap();
|
|
||||||
assert_eq!(first.mesh.slot(), second.mesh.slot());
|
|
||||||
assert_ne!(first.mesh.generation(), second.mesh.generation());
|
|
||||||
assert!(data.mesh(first.mesh).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clear_handles_all_slot_states_retains_capacity_and_never_reuses_retired() {
|
|
||||||
let mut data = data();
|
|
||||||
let mesh = data.create_mesh(info()).unwrap();
|
|
||||||
let vacant = data
|
|
||||||
.create_instance(mesh.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
|
|
||||||
.unwrap();
|
|
||||||
data.destroy_instance(vacant).unwrap();
|
|
||||||
data.instances
|
|
||||||
.slots
|
|
||||||
.force_generation(mesh.default_instance.slot(), u32::MAX);
|
|
||||||
let old_capacity = data.capacities();
|
|
||||||
data.clear().unwrap();
|
|
||||||
assert_eq!(data.capacities(), old_capacity);
|
|
||||||
assert_eq!(data.mesh_count(), 0);
|
|
||||||
assert_eq!(data.instance_count(), 0);
|
|
||||||
assert!(data.mesh(mesh.mesh).is_none());
|
|
||||||
assert!(matches!(
|
|
||||||
data.instances.slots.states[mesh.default_instance.slot() as usize],
|
|
||||||
SlotState::Retired
|
|
||||||
));
|
|
||||||
let new_mesh = data.create_mesh(info()).unwrap();
|
|
||||||
assert_ne!(
|
|
||||||
new_mesh.default_instance.slot(),
|
|
||||||
mesh.default_instance.slot()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn capacity_math_has_exact_bounded_and_unbounded_overflow_behavior() {
|
|
||||||
assert_eq!(next_capacity(0, 1, None, "x"), Ok(1));
|
|
||||||
assert_eq!(next_capacity(1, 2, None, "x"), Ok(2));
|
|
||||||
assert_eq!(next_capacity(2, 3, Some(3), "x"), Ok(3));
|
|
||||||
assert_eq!(
|
|
||||||
next_capacity(u32::MAX - 1, u32::MAX, Some(u32::MAX), "x"),
|
|
||||||
Ok(u32::MAX)
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
next_capacity(u32::MAX - 1, u32::MAX, None, "x"),
|
|
||||||
Err(RenderDataError::CapacityOverflow { .. })
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
next_capacity(2, 4, Some(3), "x"),
|
|
||||||
Err(RenderDataError::CapacityExceeded { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn all_storage_classes_grow_and_retired_slots_force_max_checked_append() {
|
|
||||||
let mut data = data();
|
|
||||||
let mesh = data.create_mesh(info()).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
data.capacities(),
|
|
||||||
RenderDataCapacities {
|
|
||||||
vertices: 3,
|
|
||||||
indices: 3,
|
|
||||||
meshes: 1,
|
|
||||||
instances: 1,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
data.create_instance(mesh.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(data.capacities().instances, 2);
|
|
||||||
|
|
||||||
let mut slots = SlotTable::new(0, Some(1), "test").unwrap();
|
|
||||||
slots.reserve_for_len(1, "test").unwrap();
|
|
||||||
let prepared = slots.prepare().unwrap();
|
|
||||||
slots.commit(prepared);
|
|
||||||
slots.force_generation(0, u32::MAX);
|
|
||||||
slots.remove(0, u32::MAX);
|
|
||||||
assert!(matches!(
|
|
||||||
slots.reserve_for_len(slots.required_len_for_prepare().unwrap(), "test"),
|
|
||||||
Err(RenderDataError::CapacityExceeded { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn allocator_checks_errors_splits_first_fit_coalesces_and_trims_tail() {
|
|
||||||
let mut allocator = RangeAllocator::default();
|
|
||||||
assert_eq!(allocator.allocate(0), Err(RenderDataError::EmptyRange));
|
|
||||||
let left = allocator.allocate(2).unwrap();
|
|
||||||
let middle = allocator.allocate(4).unwrap();
|
|
||||||
let right = allocator.allocate(2).unwrap();
|
|
||||||
assert_eq!(allocator.free(middle.clone()), Ok(8));
|
|
||||||
assert_eq!(allocator.allocate(2).unwrap(), 2..4);
|
|
||||||
assert_eq!(allocator.free(2..4), Ok(8));
|
|
||||||
assert_eq!(allocator.free(2..4), Err(RenderDataError::RangeOverlap));
|
|
||||||
assert_eq!(allocator.free(8..9), Err(RenderDataError::RangeOutOfBounds));
|
|
||||||
assert_eq!(allocator.free(3..3), Err(RenderDataError::EmptyRange));
|
|
||||||
assert_eq!(allocator.free(left), Ok(8));
|
|
||||||
assert_eq!(allocator.free(right), Ok(0));
|
|
||||||
|
|
||||||
let mut bridge = RangeAllocator::default();
|
|
||||||
bridge.allocate(6).unwrap();
|
|
||||||
bridge.free(0..2).unwrap();
|
|
||||||
bridge.free(4..6).unwrap();
|
|
||||||
bridge.free(2..4).unwrap();
|
|
||||||
assert_eq!(bridge.high_water(), 0);
|
|
||||||
|
|
||||||
let mut overflow = RangeAllocator::default();
|
|
||||||
overflow.high_water = u32::MAX;
|
|
||||||
assert_eq!(overflow.allocate(1), Err(RenderDataError::RangeOverflow));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn streams_remain_coordinated_across_interior_delete_tail_delete_and_reuse() {
|
|
||||||
let mut data = data();
|
|
||||||
let first = data.create_mesh(info()).unwrap();
|
|
||||||
let second = data.create_mesh(info()).unwrap();
|
|
||||||
assert_eq!(data.streams().positions.len(), 6);
|
|
||||||
assert_eq!(data.indices().len(), 6);
|
|
||||||
data.destroy_mesh(first.mesh).unwrap();
|
|
||||||
assert_eq!(data.streams().positions.len(), 6);
|
|
||||||
let reused = data.create_mesh(info()).unwrap();
|
|
||||||
assert_eq!(data.mesh(reused.mesh).unwrap().geometry.vertex_start, 0);
|
|
||||||
data.destroy_mesh(second.mesh).unwrap();
|
|
||||||
assert_eq!(data.streams().positions.len(), 3);
|
|
||||||
assert_eq!(data.streams().normals.len(), 3);
|
|
||||||
assert_eq!(data.streams().tangents.len(), 3);
|
|
||||||
assert_eq!(data.streams().uvs.len(), 3);
|
|
||||||
assert_eq!(data.indices().len(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn failed_default_instance_preparation_rolls_back_empty_and_existing_geometry() {
|
|
||||||
let mut data = RenderData::new(RenderDataConfig {
|
|
||||||
initial_vertices: 0,
|
|
||||||
initial_indices: 0,
|
|
||||||
initial_meshes: 0,
|
|
||||||
initial_instances: 0,
|
|
||||||
max_instances: Some(0),
|
|
||||||
..RenderDataConfig::default()
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
for _ in 0..2 {
|
|
||||||
let generations = data.meshes.slots.generations.clone();
|
|
||||||
assert!(matches!(
|
|
||||||
data.create_mesh(info()),
|
|
||||||
Err(RenderDataError::CapacityExceeded {
|
|
||||||
resource: "instances",
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
assert_eq!(data.vertices.allocator.high_water(), 0);
|
|
||||||
assert_eq!(data.indices.allocator.high_water(), 0);
|
|
||||||
assert!(data.streams().positions.is_empty());
|
|
||||||
assert!(data.indices().is_empty());
|
|
||||||
assert_eq!(data.meshes.slots.generations, generations);
|
|
||||||
}
|
|
||||||
|
|
||||||
data.instances.slots.max_capacity = Some(1);
|
|
||||||
let existing = data.create_mesh(info()).unwrap();
|
|
||||||
data.instances.slots.max_capacity = Some(0);
|
|
||||||
assert!(data.create_mesh(info()).is_err());
|
|
||||||
assert_eq!(data.vertices.allocator.high_water(), 3);
|
|
||||||
assert_eq!(data.mesh_count(), 1);
|
|
||||||
assert!(data.mesh(existing.mesh).is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn aabb_supports_one_point_and_multiple_points() {
|
|
||||||
let point = [[2.0, -3.0, 4.0]];
|
|
||||||
let normal = [[0.0, 1.0, 0.0]];
|
|
||||||
let uv = [[0.0, 0.0]];
|
|
||||||
let index = [0];
|
|
||||||
let mut one = info();
|
|
||||||
one.positions = &point;
|
|
||||||
one.normals = &normal;
|
|
||||||
one.tangents = &[[1.0, 0.0, 0.0, 1.0]];
|
|
||||||
one.uvs = &uv;
|
|
||||||
one.indices = &index;
|
|
||||||
let mut data = data();
|
|
||||||
let mesh = data.create_mesh(one).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
data.mesh(mesh.mesh).unwrap().local_aabb,
|
|
||||||
Aabb {
|
|
||||||
min: point[0],
|
|
||||||
max: point[0]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
let mesh = data.create_mesh(info()).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
data.mesh(mesh.mesh).unwrap().local_aabb,
|
|
||||||
Aabb {
|
|
||||||
min: [-1.0, -2.0, -3.0],
|
|
||||||
max: [4.0, 2.0, 3.0],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_geometry_matrix_is_rejected_without_consumption() {
|
|
||||||
let mut data = data();
|
|
||||||
let mut candidate = info();
|
|
||||||
candidate.positions = &[];
|
|
||||||
assert_eq!(
|
|
||||||
data.create_mesh(candidate).unwrap_err(),
|
|
||||||
RenderDataError::EmptyVertices
|
|
||||||
);
|
|
||||||
let short_normals = &NORMALS[..2];
|
|
||||||
let mut candidate = info();
|
|
||||||
candidate.normals = short_normals;
|
|
||||||
assert_eq!(
|
|
||||||
data.create_mesh(candidate).unwrap_err(),
|
|
||||||
RenderDataError::MismatchedVertexStreams
|
|
||||||
);
|
|
||||||
let short_tangents = &TANGENTS[..2];
|
|
||||||
let mut candidate = info();
|
|
||||||
candidate.tangents = short_tangents;
|
|
||||||
assert_eq!(
|
|
||||||
data.create_mesh(candidate).unwrap_err(),
|
|
||||||
RenderDataError::MismatchedVertexStreams
|
|
||||||
);
|
|
||||||
let short_uvs = &UVS[..2];
|
|
||||||
let mut candidate = info();
|
|
||||||
candidate.uvs = short_uvs;
|
|
||||||
assert_eq!(
|
|
||||||
data.create_mesh(candidate).unwrap_err(),
|
|
||||||
RenderDataError::MismatchedVertexStreams
|
|
||||||
);
|
|
||||||
let mut candidate = info();
|
|
||||||
candidate.indices = &[];
|
|
||||||
assert_eq!(
|
|
||||||
data.create_mesh(candidate).unwrap_err(),
|
|
||||||
RenderDataError::EmptyIndices
|
|
||||||
);
|
|
||||||
|
|
||||||
for stream in 0..4 {
|
|
||||||
for bad in [f32::NAN, f32::INFINITY] {
|
|
||||||
let mut positions = POSITIONS;
|
|
||||||
let mut normals = NORMALS;
|
|
||||||
let mut tangents = TANGENTS;
|
|
||||||
let mut uvs = UVS;
|
|
||||||
match stream {
|
|
||||||
0 => positions[0][0] = bad,
|
|
||||||
1 => normals[0][0] = bad,
|
|
||||||
2 => tangents[0][0] = bad,
|
|
||||||
_ => uvs[0][0] = bad,
|
|
||||||
}
|
|
||||||
let mut candidate = info();
|
|
||||||
candidate.positions = &positions;
|
|
||||||
candidate.normals = &normals;
|
|
||||||
candidate.tangents = &tangents;
|
|
||||||
candidate.uvs = &uvs;
|
|
||||||
assert_eq!(
|
|
||||||
data.create_mesh(candidate).unwrap_err(),
|
|
||||||
RenderDataError::NonFiniteGeometry
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let invalid = [3];
|
|
||||||
let mut candidate = info();
|
|
||||||
candidate.indices = &invalid;
|
|
||||||
assert_eq!(
|
|
||||||
data.create_mesh(candidate).unwrap_err(),
|
|
||||||
RenderDataError::IndexOutOfBounds
|
|
||||||
);
|
|
||||||
let valid_last = [2];
|
|
||||||
let mut candidate = info();
|
|
||||||
candidate.indices = &valid_last;
|
|
||||||
assert!(data.create_mesh(candidate).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normal_matrices_and_failed_transform_operations_are_transactional() {
|
|
||||||
let mut data = data();
|
|
||||||
let mesh = data.create_mesh(info()).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
data.instance(mesh.default_instance).unwrap().normal,
|
|
||||||
IDENTITY_NORMAL_MATRIX
|
|
||||||
);
|
|
||||||
let translation = [
|
|
||||||
[1.0, 0.0, 0.0, 0.0],
|
|
||||||
[0.0, 1.0, 0.0, 0.0],
|
|
||||||
[0.0, 0.0, 1.0, 0.0],
|
|
||||||
[4.0, 5.0, 6.0, 1.0],
|
|
||||||
];
|
|
||||||
data.set_instance_transform(mesh.default_instance, translation)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
data.instance(mesh.default_instance).unwrap().normal,
|
|
||||||
IDENTITY_NORMAL_MATRIX
|
|
||||||
);
|
|
||||||
let scale = [
|
|
||||||
[2.0, 0.0, 0.0, 0.0],
|
|
||||||
[0.0, 4.0, 0.0, 0.0],
|
|
||||||
[0.0, 0.0, 0.5, 0.0],
|
|
||||||
[0.0, 0.0, 0.0, 1.0],
|
|
||||||
];
|
|
||||||
data.set_instance_transform(mesh.default_instance, scale)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
data.instance(mesh.default_instance).unwrap().normal,
|
|
||||||
[[0.5, 0.0, 0.0], [0.0, 0.25, 0.0], [0.0, 0.0, 2.0]]
|
|
||||||
);
|
|
||||||
let rotation = [
|
|
||||||
[0.0, 1.0, 0.0, 0.0],
|
|
||||||
[-1.0, 0.0, 0.0, 0.0],
|
|
||||||
[0.0, 0.0, 1.0, 0.0],
|
|
||||||
[0.0, 0.0, 0.0, 1.0],
|
|
||||||
];
|
|
||||||
data.set_instance_transform(mesh.default_instance, rotation)
|
|
||||||
.unwrap();
|
|
||||||
let old = data.instance(mesh.default_instance).unwrap();
|
|
||||||
for invalid in [[[0.0; 4]; 4], {
|
|
||||||
let mut value = IDENTITY_MODEL_TRANSFORM;
|
|
||||||
value[0][0] = f32::INFINITY;
|
|
||||||
value
|
|
||||||
}] {
|
|
||||||
assert_eq!(
|
|
||||||
data.set_instance_transform(mesh.default_instance, invalid),
|
|
||||||
Err(RenderDataError::InvalidTransform)
|
|
||||||
);
|
|
||||||
assert_eq!(data.instance(mesh.default_instance).unwrap(), old);
|
|
||||||
let count = data.instance_count();
|
|
||||||
assert_eq!(
|
|
||||||
data.create_instance(mesh.mesh, invalid, InstanceType::ZERO),
|
|
||||||
Err(RenderDataError::InvalidTransform)
|
|
||||||
);
|
|
||||||
assert_eq!(data.instance_count(), count);
|
|
||||||
let mut candidate = info();
|
|
||||||
candidate.default_transform = invalid;
|
|
||||||
assert_eq!(
|
|
||||||
data.create_mesh(candidate).unwrap_err(),
|
|
||||||
RenderDataError::InvalidTransform
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn destroying_mesh_invalidates_exact_owner_instances_with_reused_generations() {
|
|
||||||
let mut data = data();
|
|
||||||
let first = data.create_mesh(info()).unwrap();
|
|
||||||
let second = data.create_mesh(info()).unwrap();
|
|
||||||
let first_extra = data
|
|
||||||
.create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
|
|
||||||
.unwrap();
|
|
||||||
let second_extra = data
|
|
||||||
.create_instance(second.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
|
|
||||||
.unwrap();
|
|
||||||
data.destroy_instance(first_extra).unwrap();
|
|
||||||
let reused = data
|
|
||||||
.create_instance(second.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(first_extra.slot(), reused.slot());
|
|
||||||
data.destroy_mesh(first.mesh).unwrap();
|
|
||||||
assert!(data.instance(first.default_instance).is_none());
|
|
||||||
assert!(data.instance(second.default_instance).is_some());
|
|
||||||
assert!(data.instance(second_extra).is_some());
|
|
||||||
assert!(data.instance(reused).is_some());
|
|
||||||
assert_eq!(data.instances().count(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn revision_changes_only_after_success_and_replacement_rejects_old_handles() {
|
|
||||||
let mut data = data();
|
|
||||||
assert_eq!(data.revision(), 0);
|
|
||||||
assert!(data.destroy_mesh(MeshHandle::from_parts(9, 9)).is_err());
|
|
||||||
assert_eq!(data.revision(), 0);
|
|
||||||
let old = data.create_mesh(info()).unwrap();
|
|
||||||
assert_eq!(data.revision(), 1);
|
|
||||||
let mut stage = data.replacement_stage().unwrap();
|
|
||||||
let new = stage.create_mesh(info()).unwrap();
|
|
||||||
assert_ne!(old.mesh, new.mesh);
|
|
||||||
data.replace_with(stage).unwrap();
|
|
||||||
assert_eq!(data.revision(), 2);
|
|
||||||
assert!(data.mesh(old.mesh).is_none());
|
|
||||||
assert!(data.mesh(new.mesh).is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn replacement_stage_is_rejected_after_source_mutation() {
|
|
||||||
let mut data = data();
|
|
||||||
let original = data.create_mesh(info()).unwrap();
|
|
||||||
let mut stage = data.replacement_stage().unwrap();
|
|
||||||
stage.create_mesh(info()).unwrap();
|
|
||||||
|
|
||||||
data.destroy_mesh(original.mesh).unwrap();
|
|
||||||
let current = data.create_mesh(info()).unwrap();
|
|
||||||
assert_eq!(current.mesh.slot(), original.mesh.slot());
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
data.replace_with(stage),
|
|
||||||
Err(RenderDataError::StaleReplacementStage)
|
|
||||||
);
|
|
||||||
assert!(data.mesh(current.mesh).is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn replacement_stage_is_rejected_by_a_different_render_data() {
|
|
||||||
let source = data();
|
|
||||||
let stage = source.replacement_stage().unwrap();
|
|
||||||
let mut other = data();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
other.replace_with(stage),
|
|
||||||
Err(RenderDataError::StaleReplacementStage)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn revision_overflow_rejects_mutation_without_committing() {
|
|
||||||
let mut data = data();
|
|
||||||
data.revision = u64::MAX;
|
|
||||||
assert_eq!(
|
|
||||||
data.create_mesh(info()),
|
|
||||||
Err(RenderDataError::RevisionOverflow)
|
|
||||||
);
|
|
||||||
assert_eq!(data.mesh_count(), 0);
|
|
||||||
assert_eq!(data.revision(), u64::MAX);
|
|
||||||
}
|
|
||||||
@@ -1,734 +0,0 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
use ultraviolet::{Mat4, Vec3};
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
InstanceType, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform, RenderData,
|
|
||||||
RenderDataError, ReplacementStage,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAGIC: u32 = u32::from_le_bytes(*b"YRDP");
|
|
||||||
const VERSION: u32 = 1;
|
|
||||||
const HEADER_BYTES: usize = 16;
|
|
||||||
const BASE_COLOR_TEXTURE: u32 = 1 << 0;
|
|
||||||
const METALLIC_ROUGHNESS_TEXTURE: u32 = 1 << 1;
|
|
||||||
const NORMAL_TEXTURE: u32 = 1 << 2;
|
|
||||||
const OCCLUSION_TEXTURE: u32 = 1 << 3;
|
|
||||||
const EMISSIVE_TEXTURE: u32 = 1 << 4;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum AlphaMode {
|
|
||||||
#[default]
|
|
||||||
Opaque,
|
|
||||||
Mask,
|
|
||||||
Blend,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
||||||
pub struct TextureReference {
|
|
||||||
pub texture: usize,
|
|
||||||
pub tex_coord: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct Material {
|
|
||||||
pub key: MaterialKey,
|
|
||||||
pub base_color_factor: [f32; 4],
|
|
||||||
pub metallic_factor: f32,
|
|
||||||
pub roughness_factor: f32,
|
|
||||||
pub emissive_factor: [f32; 3],
|
|
||||||
pub ior: f32,
|
|
||||||
pub alpha_mode: AlphaMode,
|
|
||||||
pub alpha_cutoff: f32,
|
|
||||||
pub double_sided: bool,
|
|
||||||
pub base_color_texture: Option<TextureReference>,
|
|
||||||
pub metallic_roughness_texture: Option<TextureReference>,
|
|
||||||
pub normal_texture: Option<TextureReference>,
|
|
||||||
pub normal_scale: f32,
|
|
||||||
pub occlusion_texture: Option<TextureReference>,
|
|
||||||
pub occlusion_strength: f32,
|
|
||||||
pub emissive_texture: Option<TextureReference>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Material {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
key: MaterialKey::DEFAULT,
|
|
||||||
base_color_factor: [1.0; 4],
|
|
||||||
metallic_factor: 1.0,
|
|
||||||
roughness_factor: 1.0,
|
|
||||||
emissive_factor: [0.0; 3],
|
|
||||||
ior: 1.5,
|
|
||||||
alpha_mode: AlphaMode::Opaque,
|
|
||||||
alpha_cutoff: 0.5,
|
|
||||||
double_sided: false,
|
|
||||||
base_color_texture: None,
|
|
||||||
metallic_roughness_texture: None,
|
|
||||||
normal_texture: None,
|
|
||||||
normal_scale: 1.0,
|
|
||||||
occlusion_texture: None,
|
|
||||||
occlusion_strength: 1.0,
|
|
||||||
emissive_texture: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SIMD-aligned material row shared with external render-data writers and the GPU.
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
|
||||||
pub struct MaterialState {
|
|
||||||
pub base_color_factor: [f32; 4],
|
|
||||||
pub emissive_factor: [f32; 4],
|
|
||||||
pub surface_factors: [f32; 4],
|
|
||||||
pub alpha_optics: [f32; 4],
|
|
||||||
pub flags: [u32; 4],
|
|
||||||
pub uv_sets: [u32; 4],
|
|
||||||
pub debug_extras: [u32; 4],
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enabled(reference: Option<TextureReference>, bit: u32) -> u32 {
|
|
||||||
reference
|
|
||||||
.filter(|value| value.tex_coord == 0)
|
|
||||||
.map_or(0, |_| bit)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&Material> for MaterialState {
|
|
||||||
fn from(value: &Material) -> Self {
|
|
||||||
Self {
|
|
||||||
base_color_factor: value.base_color_factor,
|
|
||||||
emissive_factor: [
|
|
||||||
value.emissive_factor[0],
|
|
||||||
value.emissive_factor[1],
|
|
||||||
value.emissive_factor[2],
|
|
||||||
0.0,
|
|
||||||
],
|
|
||||||
surface_factors: [
|
|
||||||
value.metallic_factor,
|
|
||||||
value.roughness_factor,
|
|
||||||
value.normal_scale,
|
|
||||||
value.occlusion_strength,
|
|
||||||
],
|
|
||||||
alpha_optics: [
|
|
||||||
match value.alpha_mode {
|
|
||||||
AlphaMode::Opaque => 0.0,
|
|
||||||
AlphaMode::Mask => 1.0,
|
|
||||||
AlphaMode::Blend => 2.0,
|
|
||||||
},
|
|
||||||
value.alpha_cutoff,
|
|
||||||
value.ior,
|
|
||||||
if value.ior == 0.0 {
|
|
||||||
1.0
|
|
||||||
} else {
|
|
||||||
((value.ior - 1.0) / (value.ior + 1.0)).powi(2)
|
|
||||||
},
|
|
||||||
],
|
|
||||||
flags: [
|
|
||||||
enabled(value.base_color_texture, BASE_COLOR_TEXTURE)
|
|
||||||
| enabled(value.metallic_roughness_texture, METALLIC_ROUGHNESS_TEXTURE)
|
|
||||||
| enabled(value.normal_texture, NORMAL_TEXTURE)
|
|
||||||
| enabled(value.occlusion_texture, OCCLUSION_TEXTURE)
|
|
||||||
| enabled(value.emissive_texture, EMISSIVE_TEXTURE),
|
|
||||||
u32::from(value.double_sided),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
],
|
|
||||||
uv_sets: [
|
|
||||||
value.base_color_texture.map_or(0, |value| value.tex_coord),
|
|
||||||
value
|
|
||||||
.metallic_roughness_texture
|
|
||||||
.map_or(0, |value| value.tex_coord),
|
|
||||||
value.normal_texture.map_or(0, |value| value.tex_coord),
|
|
||||||
value.occlusion_texture.map_or(0, |value| value.tex_coord),
|
|
||||||
],
|
|
||||||
debug_extras: [
|
|
||||||
value.emissive_texture.map_or(0, |value| value.tex_coord),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MaterialState {
|
|
||||||
pub const LANES: u32 = 28;
|
|
||||||
|
|
||||||
pub fn words(self) -> [u32; Self::LANES as usize] {
|
|
||||||
bytemuck::cast(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_words(words: [u32; Self::LANES as usize]) -> Self {
|
|
||||||
bytemuck::cast(words)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const _: [(); 112] = [(); std::mem::size_of::<MaterialState>()];
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum FilterMode {
|
|
||||||
Nearest,
|
|
||||||
#[default]
|
|
||||||
Linear,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum AddressMode {
|
|
||||||
ClampToEdge,
|
|
||||||
MirrorRepeat,
|
|
||||||
#[default]
|
|
||||||
Repeat,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
||||||
pub struct TextureMetadata {
|
|
||||||
pub image: usize,
|
|
||||||
pub sampler: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
||||||
pub struct SamplerMetadata {
|
|
||||||
#[serde(default)]
|
|
||||||
pub mag_filter: FilterMode,
|
|
||||||
#[serde(default)]
|
|
||||||
pub min_filter: FilterMode,
|
|
||||||
#[serde(default)]
|
|
||||||
pub mipmap_filter: FilterMode,
|
|
||||||
#[serde(default)]
|
|
||||||
pub address_u: AddressMode,
|
|
||||||
#[serde(default)]
|
|
||||||
pub address_v: AddressMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ImageMetadata {
|
|
||||||
pub mime_type: String,
|
|
||||||
pub encoded_data: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct UploadedGeometry {
|
|
||||||
pub id: u32,
|
|
||||||
pub material: MaterialKey,
|
|
||||||
pub instance_type: InstanceType,
|
|
||||||
pub positions: Vec<[f32; 3]>,
|
|
||||||
pub normals: Vec<[f32; 3]>,
|
|
||||||
pub tangents: Vec<[f32; 4]>,
|
|
||||||
pub uvs: Vec<[f32; 2]>,
|
|
||||||
pub indices: Vec<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct UploadedOccurrence {
|
|
||||||
pub geometry: u32,
|
|
||||||
pub transform: ModelTransform,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct RenderDataUpload {
|
|
||||||
pub geometries: Vec<UploadedGeometry>,
|
|
||||||
pub occurrences: Vec<UploadedOccurrence>,
|
|
||||||
pub materials: Vec<Material>,
|
|
||||||
pub textures: Vec<TextureMetadata>,
|
|
||||||
pub samplers: Vec<SamplerMetadata>,
|
|
||||||
pub images: Vec<ImageMetadata>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub struct ModelBounds {
|
|
||||||
pub min: [f32; 3],
|
|
||||||
pub max: [f32; 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModelBounds {
|
|
||||||
fn include(&mut self, point: [f32; 3]) {
|
|
||||||
for axis in 0..3 {
|
|
||||||
self.min[axis] = self.min[axis].min(point[axis]);
|
|
||||||
self.max[axis] = self.max[axis].max(point[axis]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct InstalledRenderData {
|
|
||||||
pub meshes: Vec<MeshHandle>,
|
|
||||||
pub bounds: Option<ModelBounds>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PreparedRenderData {
|
|
||||||
pub stage: ReplacementStage,
|
|
||||||
pub installed: InstalledRenderData,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum RenderDataUploadError {
|
|
||||||
#[error("render-data packet is malformed: {0}")]
|
|
||||||
Malformed(&'static str),
|
|
||||||
#[error("render-data packet metadata is invalid: {0}")]
|
|
||||||
Metadata(#[from] serde_json::Error),
|
|
||||||
#[error("render-data packet contains invalid geometry: {0}")]
|
|
||||||
InvalidGeometry(&'static str),
|
|
||||||
#[error("render-data packet contains invalid material data")]
|
|
||||||
InvalidMaterial,
|
|
||||||
#[error("failed to install uploaded render data")]
|
|
||||||
Install(#[from] RenderDataError),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
||||||
struct DataSlice {
|
|
||||||
offset: u32,
|
|
||||||
count: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
||||||
struct ByteSlice {
|
|
||||||
offset: u32,
|
|
||||||
byte_length: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
||||||
struct GeometryMetadata {
|
|
||||||
id: u32,
|
|
||||||
material: u32,
|
|
||||||
instance_type: [u32; 16],
|
|
||||||
positions: DataSlice,
|
|
||||||
normals: DataSlice,
|
|
||||||
tangents: DataSlice,
|
|
||||||
uvs: DataSlice,
|
|
||||||
indices: DataSlice,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
||||||
struct OccurrenceMetadata {
|
|
||||||
geometry: u32,
|
|
||||||
transform: [f32; 16],
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
||||||
struct MaterialMetadata {
|
|
||||||
key: u32,
|
|
||||||
base_color_factor: [f32; 4],
|
|
||||||
metallic_factor: f32,
|
|
||||||
roughness_factor: f32,
|
|
||||||
emissive_factor: [f32; 3],
|
|
||||||
ior: f32,
|
|
||||||
alpha_mode: AlphaMode,
|
|
||||||
alpha_cutoff: f32,
|
|
||||||
double_sided: bool,
|
|
||||||
base_color_texture: Option<TextureReference>,
|
|
||||||
metallic_roughness_texture: Option<TextureReference>,
|
|
||||||
normal_texture: Option<TextureReference>,
|
|
||||||
normal_scale: f32,
|
|
||||||
occlusion_texture: Option<TextureReference>,
|
|
||||||
occlusion_strength: f32,
|
|
||||||
emissive_texture: Option<TextureReference>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<MaterialMetadata> for Material {
|
|
||||||
type Error = RenderDataUploadError;
|
|
||||||
|
|
||||||
fn try_from(value: MaterialMetadata) -> Result<Self, Self::Error> {
|
|
||||||
let finite = value
|
|
||||||
.base_color_factor
|
|
||||||
.iter()
|
|
||||||
.chain(value.emissive_factor.iter())
|
|
||||||
.chain([
|
|
||||||
&value.metallic_factor,
|
|
||||||
&value.roughness_factor,
|
|
||||||
&value.ior,
|
|
||||||
&value.alpha_cutoff,
|
|
||||||
&value.normal_scale,
|
|
||||||
&value.occlusion_strength,
|
|
||||||
])
|
|
||||||
.all(|component| component.is_finite());
|
|
||||||
if !finite || (value.ior != 0.0 && value.ior < 1.0) {
|
|
||||||
return Err(RenderDataUploadError::InvalidMaterial);
|
|
||||||
}
|
|
||||||
Ok(Self {
|
|
||||||
key: MaterialKey::new(value.key),
|
|
||||||
base_color_factor: value.base_color_factor,
|
|
||||||
metallic_factor: value.metallic_factor,
|
|
||||||
roughness_factor: value.roughness_factor,
|
|
||||||
emissive_factor: value.emissive_factor,
|
|
||||||
ior: value.ior,
|
|
||||||
alpha_mode: value.alpha_mode,
|
|
||||||
alpha_cutoff: value.alpha_cutoff,
|
|
||||||
double_sided: value.double_sided,
|
|
||||||
base_color_texture: value.base_color_texture,
|
|
||||||
metallic_roughness_texture: value.metallic_roughness_texture,
|
|
||||||
normal_texture: value.normal_texture,
|
|
||||||
normal_scale: value.normal_scale,
|
|
||||||
occlusion_texture: value.occlusion_texture,
|
|
||||||
occlusion_strength: value.occlusion_strength,
|
|
||||||
emissive_texture: value.emissive_texture,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
||||||
struct ImageMetadataPacket {
|
|
||||||
mime_type: String,
|
|
||||||
data: ByteSlice,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
||||||
struct PacketMetadata {
|
|
||||||
#[serde(default)]
|
|
||||||
geometries: Vec<GeometryMetadata>,
|
|
||||||
#[serde(default)]
|
|
||||||
occurrences: Vec<OccurrenceMetadata>,
|
|
||||||
#[serde(default)]
|
|
||||||
materials: Vec<MaterialMetadata>,
|
|
||||||
#[serde(default)]
|
|
||||||
textures: Vec<TextureMetadata>,
|
|
||||||
#[serde(default)]
|
|
||||||
samplers: Vec<SamplerMetadata>,
|
|
||||||
#[serde(default)]
|
|
||||||
images: Vec<ImageMetadataPacket>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn word(bytes: &[u8], offset: usize) -> Result<u32, RenderDataUploadError> {
|
|
||||||
let raw: [u8; 4] = bytes
|
|
||||||
.get(offset..offset + 4)
|
|
||||||
.ok_or(RenderDataUploadError::Malformed("header is truncated"))?
|
|
||||||
.try_into()
|
|
||||||
.unwrap();
|
|
||||||
Ok(u32::from_le_bytes(raw))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn range(payload: &[u8], offset: u32, byte_length: usize) -> Result<&[u8], RenderDataUploadError> {
|
|
||||||
let start = usize::try_from(offset)
|
|
||||||
.map_err(|_| RenderDataUploadError::Malformed("data offset exceeds usize"))?;
|
|
||||||
let end = start
|
|
||||||
.checked_add(byte_length)
|
|
||||||
.ok_or(RenderDataUploadError::Malformed("data range overflows"))?;
|
|
||||||
payload
|
|
||||||
.get(start..end)
|
|
||||||
.ok_or(RenderDataUploadError::Malformed(
|
|
||||||
"data range is out of bounds",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn f32_vectors<const N: usize>(
|
|
||||||
payload: &[u8],
|
|
||||||
slice: DataSlice,
|
|
||||||
) -> Result<Vec<[f32; N]>, RenderDataUploadError> {
|
|
||||||
if slice.offset % 4 != 0 {
|
|
||||||
return Err(RenderDataUploadError::Malformed("float data is unaligned"));
|
|
||||||
}
|
|
||||||
let count = usize::try_from(slice.count)
|
|
||||||
.map_err(|_| RenderDataUploadError::Malformed("element count exceeds usize"))?;
|
|
||||||
let byte_length = count
|
|
||||||
.checked_mul(N)
|
|
||||||
.and_then(|value| value.checked_mul(4))
|
|
||||||
.ok_or(RenderDataUploadError::Malformed(
|
|
||||||
"float data size overflows",
|
|
||||||
))?;
|
|
||||||
let bytes = range(payload, slice.offset, byte_length)?;
|
|
||||||
Ok(bytes
|
|
||||||
.chunks_exact(N * 4)
|
|
||||||
.map(|chunk| {
|
|
||||||
std::array::from_fn(|lane| {
|
|
||||||
f32::from_le_bytes(chunk[lane * 4..lane * 4 + 4].try_into().unwrap())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn u32_values(payload: &[u8], slice: DataSlice) -> Result<Vec<u32>, RenderDataUploadError> {
|
|
||||||
if slice.offset % 4 != 0 {
|
|
||||||
return Err(RenderDataUploadError::Malformed(
|
|
||||||
"integer data is unaligned",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let byte_length = usize::try_from(slice.count)
|
|
||||||
.ok()
|
|
||||||
.and_then(|count| count.checked_mul(4))
|
|
||||||
.ok_or(RenderDataUploadError::Malformed(
|
|
||||||
"integer data size overflows",
|
|
||||||
))?;
|
|
||||||
Ok(range(payload, slice.offset, byte_length)?
|
|
||||||
.chunks_exact(4)
|
|
||||||
.map(|chunk| u32::from_le_bytes(chunk.try_into().unwrap()))
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode the generic binary render-data packet accepted by core.
|
|
||||||
pub fn decode_render_data_packet(bytes: &[u8]) -> Result<RenderDataUpload, RenderDataUploadError> {
|
|
||||||
if bytes.len() < HEADER_BYTES || word(bytes, 0)? != MAGIC {
|
|
||||||
return Err(RenderDataUploadError::Malformed("magic is invalid"));
|
|
||||||
}
|
|
||||||
if word(bytes, 4)? != VERSION {
|
|
||||||
return Err(RenderDataUploadError::Malformed("version is unsupported"));
|
|
||||||
}
|
|
||||||
let metadata_len = usize::try_from(word(bytes, 8)?)
|
|
||||||
.map_err(|_| RenderDataUploadError::Malformed("metadata size exceeds usize"))?;
|
|
||||||
let payload_len = usize::try_from(word(bytes, 12)?)
|
|
||||||
.map_err(|_| RenderDataUploadError::Malformed("payload size exceeds usize"))?;
|
|
||||||
let metadata_end = HEADER_BYTES
|
|
||||||
.checked_add(metadata_len)
|
|
||||||
.ok_or(RenderDataUploadError::Malformed("metadata size overflows"))?;
|
|
||||||
let payload_start = metadata_end.checked_add(3).map(|value| value & !3).ok_or(
|
|
||||||
RenderDataUploadError::Malformed("payload alignment overflows"),
|
|
||||||
)?;
|
|
||||||
let packet_end = payload_start
|
|
||||||
.checked_add(payload_len)
|
|
||||||
.ok_or(RenderDataUploadError::Malformed("packet size overflows"))?;
|
|
||||||
if packet_end != bytes.len() {
|
|
||||||
return Err(RenderDataUploadError::Malformed(
|
|
||||||
"packet length is not exact",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let metadata: PacketMetadata = serde_json::from_slice(
|
|
||||||
bytes
|
|
||||||
.get(HEADER_BYTES..metadata_end)
|
|
||||||
.ok_or(RenderDataUploadError::Malformed("metadata is truncated"))?,
|
|
||||||
)?;
|
|
||||||
let payload = &bytes[payload_start..packet_end];
|
|
||||||
|
|
||||||
let mut geometry_ids = HashSet::new();
|
|
||||||
let geometries = metadata
|
|
||||||
.geometries
|
|
||||||
.into_iter()
|
|
||||||
.map(|geometry| {
|
|
||||||
if !geometry_ids.insert(geometry.id) {
|
|
||||||
return Err(RenderDataUploadError::InvalidGeometry(
|
|
||||||
"geometry id is duplicated",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(UploadedGeometry {
|
|
||||||
id: geometry.id,
|
|
||||||
material: MaterialKey::new(geometry.material),
|
|
||||||
instance_type: InstanceType {
|
|
||||||
words: geometry.instance_type,
|
|
||||||
},
|
|
||||||
positions: f32_vectors(payload, geometry.positions)?,
|
|
||||||
normals: f32_vectors(payload, geometry.normals)?,
|
|
||||||
tangents: f32_vectors(payload, geometry.tangents)?,
|
|
||||||
uvs: f32_vectors(payload, geometry.uvs)?,
|
|
||||||
indices: u32_values(payload, geometry.indices)?,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
let occurrences = metadata
|
|
||||||
.occurrences
|
|
||||||
.into_iter()
|
|
||||||
.map(|occurrence| UploadedOccurrence {
|
|
||||||
geometry: occurrence.geometry,
|
|
||||||
transform: std::array::from_fn(|column| {
|
|
||||||
std::array::from_fn(|row| occurrence.transform[column * 4 + row])
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let materials = metadata
|
|
||||||
.materials
|
|
||||||
.into_iter()
|
|
||||||
.map(Material::try_from)
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
let images = metadata
|
|
||||||
.images
|
|
||||||
.into_iter()
|
|
||||||
.map(|image| -> Result<_, RenderDataUploadError> {
|
|
||||||
let byte_length = usize::try_from(image.data.byte_length)
|
|
||||||
.map_err(|_| RenderDataUploadError::Malformed("image size exceeds usize"))?;
|
|
||||||
Ok(ImageMetadata {
|
|
||||||
mime_type: image.mime_type,
|
|
||||||
encoded_data: range(payload, image.data.offset, byte_length)?.to_vec(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
Ok(RenderDataUpload {
|
|
||||||
geometries,
|
|
||||||
occurrences,
|
|
||||||
materials,
|
|
||||||
textures: metadata.textures,
|
|
||||||
samplers: metadata.samplers,
|
|
||||||
images,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn focus_bounds(points: &[[f32; 3]]) -> Option<ModelBounds> {
|
|
||||||
let first = *points.first()?;
|
|
||||||
if points.len() < 200 {
|
|
||||||
let mut bounds = ModelBounds {
|
|
||||||
min: first,
|
|
||||||
max: first,
|
|
||||||
};
|
|
||||||
for point in &points[1..] {
|
|
||||||
bounds.include(*point);
|
|
||||||
}
|
|
||||||
return Some(bounds);
|
|
||||||
}
|
|
||||||
let trim = points.len() / 100;
|
|
||||||
let mut min = [0.0; 3];
|
|
||||||
let mut max = [0.0; 3];
|
|
||||||
for axis in 0..3 {
|
|
||||||
let mut values: Vec<_> = points.iter().map(|point| point[axis]).collect();
|
|
||||||
values.sort_by(f32::total_cmp);
|
|
||||||
min[axis] = values[trim];
|
|
||||||
max[axis] = values[values.len() - trim - 1];
|
|
||||||
}
|
|
||||||
Some(ModelBounds { min, max })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prepare a complete CPU-side replacement without changing live render data.
|
|
||||||
pub fn prepare_render_data(
|
|
||||||
target: &RenderData,
|
|
||||||
upload: &RenderDataUpload,
|
|
||||||
) -> Result<PreparedRenderData, RenderDataUploadError> {
|
|
||||||
let mut stage = target.replacement_stage()?;
|
|
||||||
let mut handles = HashMap::new();
|
|
||||||
let mut mesh_handles = Vec::with_capacity(upload.geometries.len());
|
|
||||||
let mut first = HashMap::new();
|
|
||||||
for occurrence in &upload.occurrences {
|
|
||||||
first
|
|
||||||
.entry(occurrence.geometry)
|
|
||||||
.or_insert(occurrence.transform);
|
|
||||||
}
|
|
||||||
for geometry in &upload.geometries {
|
|
||||||
let transform = *first
|
|
||||||
.get(&geometry.id)
|
|
||||||
.ok_or(RenderDataUploadError::InvalidGeometry(
|
|
||||||
"geometry has no occurrence",
|
|
||||||
))?;
|
|
||||||
let created = stage.create_mesh(MeshCreateInfo {
|
|
||||||
positions: &geometry.positions,
|
|
||||||
normals: &geometry.normals,
|
|
||||||
tangents: &geometry.tangents,
|
|
||||||
uvs: &geometry.uvs,
|
|
||||||
indices: &geometry.indices,
|
|
||||||
material: geometry.material,
|
|
||||||
default_instance_type: geometry.instance_type,
|
|
||||||
default_transform: transform,
|
|
||||||
})?;
|
|
||||||
handles.insert(geometry.id, created.mesh);
|
|
||||||
mesh_handles.push(created.mesh);
|
|
||||||
}
|
|
||||||
|
|
||||||
let geometries: HashMap<_, _> = upload
|
|
||||||
.geometries
|
|
||||||
.iter()
|
|
||||||
.map(|geometry| (geometry.id, geometry))
|
|
||||||
.collect();
|
|
||||||
let mut consumed = HashSet::new();
|
|
||||||
let mut focus_points = Vec::new();
|
|
||||||
let mut bounds: Option<ModelBounds> = None;
|
|
||||||
for occurrence in &upload.occurrences {
|
|
||||||
let mesh =
|
|
||||||
*handles
|
|
||||||
.get(&occurrence.geometry)
|
|
||||||
.ok_or(RenderDataUploadError::InvalidGeometry(
|
|
||||||
"occurrence has no geometry",
|
|
||||||
))?;
|
|
||||||
if !consumed.insert(occurrence.geometry) {
|
|
||||||
let instance_type = stage.mesh(mesh).unwrap().default_instance_type;
|
|
||||||
stage.create_instance(mesh, occurrence.transform, instance_type)?;
|
|
||||||
}
|
|
||||||
let geometry = geometries[&occurrence.geometry];
|
|
||||||
let transform = Mat4::from(occurrence.transform);
|
|
||||||
focus_points.extend(geometry.positions.iter().map(|position| {
|
|
||||||
let point = transform.transform_point3(Vec3::from(*position));
|
|
||||||
[point.x, point.y, point.z]
|
|
||||||
}));
|
|
||||||
let local = stage.mesh(mesh).unwrap().local_aabb;
|
|
||||||
for x in [local.min[0], local.max[0]] {
|
|
||||||
for y in [local.min[1], local.max[1]] {
|
|
||||||
for z in [local.min[2], local.max[2]] {
|
|
||||||
let point = transform.transform_point3(Vec3::new(x, y, z));
|
|
||||||
let point = [point.x, point.y, point.z];
|
|
||||||
if let Some(existing) = bounds.as_mut() {
|
|
||||||
existing.include(point);
|
|
||||||
} else {
|
|
||||||
bounds = Some(ModelBounds {
|
|
||||||
min: point,
|
|
||||||
max: point,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bounds = focus_bounds(&focus_points).or(bounds);
|
|
||||||
Ok(PreparedRenderData {
|
|
||||||
stage,
|
|
||||||
installed: InstalledRenderData {
|
|
||||||
meshes: mesh_handles,
|
|
||||||
bounds,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn packet(metadata: serde_json::Value, payload: &[u8]) -> Vec<u8> {
|
|
||||||
let metadata = serde_json::to_vec(&metadata).unwrap();
|
|
||||||
let payload_offset = (HEADER_BYTES + metadata.len() + 3) & !3;
|
|
||||||
let mut packet = vec![0; payload_offset + payload.len()];
|
|
||||||
packet[0..4].copy_from_slice(&MAGIC.to_le_bytes());
|
|
||||||
packet[4..8].copy_from_slice(&VERSION.to_le_bytes());
|
|
||||||
packet[8..12].copy_from_slice(&(metadata.len() as u32).to_le_bytes());
|
|
||||||
packet[12..16].copy_from_slice(&(payload.len() as u32).to_le_bytes());
|
|
||||||
packet[HEADER_BYTES..HEADER_BYTES + metadata.len()].copy_from_slice(&metadata);
|
|
||||||
packet[payload_offset..].copy_from_slice(payload);
|
|
||||||
packet
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generic_packet_decodes_typed_streams_without_format_knowledge() {
|
|
||||||
let floats: Vec<f32> = [
|
|
||||||
0., 0., 0., 1., 0., 0., 0., 1., 0., // positions
|
|
||||||
0., 0., 1., 0., 0., 1., 0., 0., 1., // normals
|
|
||||||
1., 0., 0., 1., 1., 0., 0., 1., 1., 0., 0., 1., // tangents
|
|
||||||
0., 0., 1., 0., 0., 1., // uvs
|
|
||||||
]
|
|
||||||
.into();
|
|
||||||
let mut payload = bytemuck::cast_slice(&floats).to_vec();
|
|
||||||
payload.extend_from_slice(bytemuck::cast_slice(&[0u32, 1, 2]));
|
|
||||||
let upload = decode_render_data_packet(&packet(
|
|
||||||
serde_json::json!({
|
|
||||||
"geometries":[{
|
|
||||||
"id":7,"material":0,"instanceType":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
|
||||||
"positions":{"offset":0,"count":3},
|
|
||||||
"normals":{"offset":36,"count":3},
|
|
||||||
"tangents":{"offset":72,"count":3},
|
|
||||||
"uvs":{"offset":120,"count":3},
|
|
||||||
"indices":{"offset":144,"count":3}
|
|
||||||
}],
|
|
||||||
"occurrences":[{"geometry":7,"transform":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]}],
|
|
||||||
"materials":[],"textures":[],"samplers":[],"images":[]
|
|
||||||
}),
|
|
||||||
&payload,
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(upload.geometries[0].positions.len(), 3);
|
|
||||||
assert_eq!(upload.geometries[0].indices, [0, 1, 2]);
|
|
||||||
assert_eq!(upload.occurrences[0].geometry, 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn packet_length_and_ranges_are_exact() {
|
|
||||||
let mut invalid = packet(serde_json::json!({}), &[]);
|
|
||||||
invalid.push(0);
|
|
||||||
assert!(matches!(
|
|
||||||
decode_render_data_packet(&invalid),
|
|
||||||
Err(RenderDataUploadError::Malformed(
|
|
||||||
"packet length is not exact"
|
|
||||||
))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,540 +0,0 @@
|
|||||||
//! Canonical S-expression wire AST.
|
|
||||||
//!
|
|
||||||
//! Nodes are definitions and `(ref "node" "socket")` forms are references, so one
|
|
||||||
//! output may feed any number of consumers without expanding the source expression.
|
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashSet};
|
|
||||||
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
ComputePipelineDeclaration, ExecutorRef, Graph, GraphError, Node, NodeOutputRef, NodeState,
|
|
||||||
PipelineDeclarations, RenderPipelineDeclaration, MAX_AST_BYTES,
|
|
||||||
};
|
|
||||||
|
|
||||||
const AST_VERSION: u32 = 1;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
enum SExpr {
|
|
||||||
List(Vec<SExpr>),
|
|
||||||
Atom(String),
|
|
||||||
String(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Parser<'a> {
|
|
||||||
source: &'a str,
|
|
||||||
offset: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Parser<'_> {
|
|
||||||
fn skip_trivia(&mut self) {
|
|
||||||
loop {
|
|
||||||
while self
|
|
||||||
.source
|
|
||||||
.as_bytes()
|
|
||||||
.get(self.offset)
|
|
||||||
.is_some_and(u8::is_ascii_whitespace)
|
|
||||||
{
|
|
||||||
self.offset += 1;
|
|
||||||
}
|
|
||||||
if self.source.as_bytes().get(self.offset) != Some(&b';') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
while self
|
|
||||||
.source
|
|
||||||
.as_bytes()
|
|
||||||
.get(self.offset)
|
|
||||||
.is_some_and(|byte| *byte != b'\n')
|
|
||||||
{
|
|
||||||
self.offset += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expression(&mut self) -> Result<SExpr, GraphError> {
|
|
||||||
self.skip_trivia();
|
|
||||||
match self.source.as_bytes().get(self.offset).copied() {
|
|
||||||
Some(b'(') => self.list(),
|
|
||||||
Some(b'"') => self.string(),
|
|
||||||
Some(b')') | None => Err(invalid("expected expression", self.offset)),
|
|
||||||
Some(_) => self.atom(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list(&mut self) -> Result<SExpr, GraphError> {
|
|
||||||
self.offset += 1;
|
|
||||||
let mut values = Vec::new();
|
|
||||||
loop {
|
|
||||||
self.skip_trivia();
|
|
||||||
match self.source.as_bytes().get(self.offset).copied() {
|
|
||||||
Some(b')') => {
|
|
||||||
self.offset += 1;
|
|
||||||
return Ok(SExpr::List(values));
|
|
||||||
}
|
|
||||||
None => return Err(invalid("unterminated list", self.offset)),
|
|
||||||
_ => values.push(self.expression()?),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn string(&mut self) -> Result<SExpr, GraphError> {
|
|
||||||
let start = self.offset;
|
|
||||||
self.offset += 1;
|
|
||||||
let mut escaped = false;
|
|
||||||
while let Some(byte) = self.source.as_bytes().get(self.offset).copied() {
|
|
||||||
self.offset += 1;
|
|
||||||
if escaped {
|
|
||||||
escaped = false;
|
|
||||||
} else if byte == b'\\' {
|
|
||||||
escaped = true;
|
|
||||||
} else if byte == b'"' {
|
|
||||||
let encoded = &self.source[start..self.offset];
|
|
||||||
let value = serde_json::from_str(encoded)
|
|
||||||
.map_err(|_| invalid("invalid string literal", start))?;
|
|
||||||
return Ok(SExpr::String(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(invalid("unterminated string", start))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn atom(&mut self) -> Result<SExpr, GraphError> {
|
|
||||||
let start = self.offset;
|
|
||||||
while self.source.as_bytes().get(self.offset).is_some_and(|byte| {
|
|
||||||
!byte.is_ascii_whitespace() && !matches!(*byte, b'(' | b')' | b'"' | b';')
|
|
||||||
}) {
|
|
||||||
self.offset += 1;
|
|
||||||
}
|
|
||||||
if start == self.offset {
|
|
||||||
Err(invalid("invalid token", start))
|
|
||||||
} else {
|
|
||||||
Ok(SExpr::Atom(self.source[start..self.offset].to_owned()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn invalid(message: impl Into<String>, offset: usize) -> GraphError {
|
|
||||||
let message = message.into();
|
|
||||||
GraphError {
|
|
||||||
code: "GRAPH_AST_INVALID",
|
|
||||||
message: message.clone(),
|
|
||||||
details: serde_json::json!({"message":message,"offset":offset}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list(value: &SExpr) -> Result<&[SExpr], GraphError> {
|
|
||||||
match value {
|
|
||||||
SExpr::List(values) => Ok(values),
|
|
||||||
_ => Err(invalid("expected list", 0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn atom(value: &SExpr) -> Result<&str, GraphError> {
|
|
||||||
match value {
|
|
||||||
SExpr::Atom(value) => Ok(value),
|
|
||||||
_ => Err(invalid("expected symbol", 0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn string(value: &SExpr) -> Result<String, GraphError> {
|
|
||||||
match value {
|
|
||||||
SExpr::String(value) => Ok(value.clone()),
|
|
||||||
_ => Err(invalid("expected string", 0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn u32_value(value: &SExpr) -> Result<u32, GraphError> {
|
|
||||||
atom(value)?.parse().map_err(|_| invalid("expected u32", 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn named_fields<'a>(values: &'a [SExpr]) -> Result<BTreeMap<&'a str, &'a [SExpr]>, GraphError> {
|
|
||||||
let mut fields = BTreeMap::new();
|
|
||||||
for value in values {
|
|
||||||
let field = list(value)?;
|
|
||||||
let Some(name) = field.first() else {
|
|
||||||
return Err(invalid("empty field", 0));
|
|
||||||
};
|
|
||||||
let name = atom(name)?;
|
|
||||||
if fields.insert(name, &field[1..]).is_some() {
|
|
||||||
return Err(invalid(format!("duplicate field '{name}'"), 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn exact_field<'a>(
|
|
||||||
fields: &BTreeMap<&str, &'a [SExpr]>,
|
|
||||||
name: &str,
|
|
||||||
length: usize,
|
|
||||||
) -> Result<&'a [SExpr], GraphError> {
|
|
||||||
let values = fields
|
|
||||||
.get(name)
|
|
||||||
.copied()
|
|
||||||
.ok_or_else(|| invalid(format!("missing field '{name}'"), 0))?;
|
|
||||||
if values.len() != length {
|
|
||||||
return Err(invalid(format!("field '{name}' has invalid arity"), 0));
|
|
||||||
}
|
|
||||||
Ok(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_value(value: &SExpr) -> Result<Value, GraphError> {
|
|
||||||
match value {
|
|
||||||
SExpr::String(value) => Ok(Value::String(value.clone())),
|
|
||||||
SExpr::Atom(value) if value == "true" => Ok(Value::Bool(true)),
|
|
||||||
SExpr::Atom(value) if value == "false" => Ok(Value::Bool(false)),
|
|
||||||
SExpr::Atom(value) if value == "null" => Ok(Value::Null),
|
|
||||||
SExpr::Atom(value) => serde_json::from_str(value)
|
|
||||||
.map_err(|_| invalid("value atom must be a finite JSON number", 0)),
|
|
||||||
SExpr::List(values)
|
|
||||||
if values.first().and_then(|value| atom(value).ok()) == Some("array") =>
|
|
||||||
{
|
|
||||||
values[1..]
|
|
||||||
.iter()
|
|
||||||
.map(json_value)
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.map(Value::Array)
|
|
||||||
}
|
|
||||||
SExpr::List(values)
|
|
||||||
if values.first().and_then(|value| atom(value).ok()) == Some("object") =>
|
|
||||||
{
|
|
||||||
let mut object = serde_json::Map::new();
|
|
||||||
for field in &values[1..] {
|
|
||||||
let field = list(field)?;
|
|
||||||
if field.len() != 3 || atom(&field[0])? != "field" {
|
|
||||||
return Err(invalid("object entries must be (field string value)", 0));
|
|
||||||
}
|
|
||||||
let key = string(&field[1])?;
|
|
||||||
if object.insert(key.clone(), json_value(&field[2])?).is_some() {
|
|
||||||
return Err(invalid(format!("duplicate object field '{key}'"), 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Value::Object(object))
|
|
||||||
}
|
|
||||||
_ => Err(invalid("invalid data value", 0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn node(value: &SExpr) -> Result<Node, GraphError> {
|
|
||||||
let values = list(value)?;
|
|
||||||
if values.len() < 4 || atom(&values[0])? != "node" {
|
|
||||||
return Err(invalid("invalid node definition", 0));
|
|
||||||
}
|
|
||||||
let id = string(&values[1])?;
|
|
||||||
let state = match atom(&values[2])? {
|
|
||||||
"enabled" => NodeState::Enabled,
|
|
||||||
"muted" => NodeState::Muted,
|
|
||||||
_ => return Err(invalid("node state must be enabled or muted", 0)),
|
|
||||||
};
|
|
||||||
let fields = named_fields(&values[3..])?;
|
|
||||||
if fields.len() != 3 {
|
|
||||||
return Err(invalid("node requires executor, params, and inputs", 0));
|
|
||||||
}
|
|
||||||
let executor = exact_field(&fields, "executor", 2)?;
|
|
||||||
let parameters = json_value(&exact_field(&fields, "params", 1)?[0])?;
|
|
||||||
let input_forms = fields
|
|
||||||
.get("inputs")
|
|
||||||
.copied()
|
|
||||||
.ok_or_else(|| invalid("missing field 'inputs'", 0))?;
|
|
||||||
let mut inputs = BTreeMap::new();
|
|
||||||
for input in input_forms {
|
|
||||||
let input = list(input)?;
|
|
||||||
if input.len() < 2 || atom(&input[0])? != "input" {
|
|
||||||
return Err(invalid("invalid input definition", 0));
|
|
||||||
}
|
|
||||||
let name = string(&input[1])?;
|
|
||||||
let mut references = Vec::new();
|
|
||||||
for reference in &input[2..] {
|
|
||||||
let reference = list(reference)?;
|
|
||||||
if reference.len() != 3 || atom(&reference[0])? != "ref" {
|
|
||||||
return Err(invalid("invalid DAG reference", 0));
|
|
||||||
}
|
|
||||||
references.push(NodeOutputRef {
|
|
||||||
node: string(&reference[1])?,
|
|
||||||
socket: string(&reference[2])?,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if inputs.insert(name.clone(), references).is_some() {
|
|
||||||
return Err(invalid(format!("duplicate input '{name}'"), 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Node {
|
|
||||||
id,
|
|
||||||
state,
|
|
||||||
executor: ExecutorRef {
|
|
||||||
key: string(&executor[0])?,
|
|
||||||
version: u32_value(&executor[1])?,
|
|
||||||
},
|
|
||||||
parameters,
|
|
||||||
inputs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses the only render-graph wire format accepted by Yawn core.
|
|
||||||
pub fn parse(bytes: &[u8]) -> Result<Graph, GraphError> {
|
|
||||||
if bytes.len() > MAX_AST_BYTES {
|
|
||||||
return Err(GraphError::new(
|
|
||||||
"GRAPH_PAYLOAD_TOO_LARGE",
|
|
||||||
"graph AST exceeds 1 MiB",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let source = std::str::from_utf8(bytes)
|
|
||||||
.map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph AST is not UTF-8"))?;
|
|
||||||
let mut parser = Parser { source, offset: 0 };
|
|
||||||
let root = parser.expression()?;
|
|
||||||
parser.skip_trivia();
|
|
||||||
if parser.offset != source.len() {
|
|
||||||
return Err(invalid("trailing expression", parser.offset));
|
|
||||||
}
|
|
||||||
let root = list(&root)?;
|
|
||||||
if root.len() < 2 || atom(&root[0])? != "yawn-graph" {
|
|
||||||
return Err(invalid("root must be yawn-graph", 0));
|
|
||||||
}
|
|
||||||
if u32_value(&root[1])? != AST_VERSION {
|
|
||||||
return Err(GraphError::new(
|
|
||||||
"GRAPH_SCHEMA_UNSUPPORTED",
|
|
||||||
"render graph AST version must be 1",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let fields = named_fields(&root[2..])?;
|
|
||||||
if fields.len() != 4 {
|
|
||||||
return Err(invalid(
|
|
||||||
"graph requires id, revision, pipelines, and nodes",
|
|
||||||
0,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let graph_id = string(&exact_field(&fields, "id", 1)?[0])?;
|
|
||||||
let revision = u32_value(&exact_field(&fields, "revision", 1)?[0])?;
|
|
||||||
let pipelines_value = json_value(&exact_field(&fields, "pipelines", 1)?[0])?;
|
|
||||||
let pipelines: PipelineDeclarations = serde_json::from_value(pipelines_value)
|
|
||||||
.map_err(|error| invalid(format!("invalid pipeline declarations: {error}"), 0))?;
|
|
||||||
let nodes = fields
|
|
||||||
.get("nodes")
|
|
||||||
.copied()
|
|
||||||
.ok_or_else(|| invalid("missing field 'nodes'", 0))?
|
|
||||||
.iter()
|
|
||||||
.map(node)
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
Ok(Graph {
|
|
||||||
schema_version: 3,
|
|
||||||
graph_id,
|
|
||||||
revision,
|
|
||||||
pipelines,
|
|
||||||
nodes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn push_string(out: &mut String, value: &str) {
|
|
||||||
out.push_str(&serde_json::to_string(value).expect("strings always serialize"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn push_json(out: &mut String, value: &Value) {
|
|
||||||
match value {
|
|
||||||
Value::Null => out.push_str("null"),
|
|
||||||
Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
|
|
||||||
Value::Number(value) => out.push_str(&value.to_string()),
|
|
||||||
Value::String(value) => push_string(out, value),
|
|
||||||
Value::Array(values) => {
|
|
||||||
out.push_str("(array");
|
|
||||||
for value in values {
|
|
||||||
out.push(' ');
|
|
||||||
push_json(out, value);
|
|
||||||
}
|
|
||||||
out.push(')');
|
|
||||||
}
|
|
||||||
Value::Object(values) => {
|
|
||||||
out.push_str("(object");
|
|
||||||
let mut fields: Vec<_> = values.iter().collect();
|
|
||||||
fields.sort_by(|left, right| left.0.cmp(right.0));
|
|
||||||
for (name, value) in fields {
|
|
||||||
out.push_str(" (field ");
|
|
||||||
push_string(out, name);
|
|
||||||
out.push(' ');
|
|
||||||
push_json(out, value);
|
|
||||||
out.push(')');
|
|
||||||
}
|
|
||||||
out.push(')');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serializes an internal graph for fixtures and cross-language conformance tests.
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn serialize(graph: &Graph) -> String {
|
|
||||||
let mut out = format!("(yawn-graph {AST_VERSION}\n (id ");
|
|
||||||
push_string(&mut out, &graph.graph_id);
|
|
||||||
out.push_str(&format!(
|
|
||||||
")\n (revision {})\n (pipelines ",
|
|
||||||
graph.revision
|
|
||||||
));
|
|
||||||
push_json(
|
|
||||||
&mut out,
|
|
||||||
&serde_json::to_value(&graph.pipelines).expect("pipeline declarations serialize"),
|
|
||||||
);
|
|
||||||
out.push_str(")\n (nodes");
|
|
||||||
for node in &graph.nodes {
|
|
||||||
out.push_str("\n (node ");
|
|
||||||
push_string(&mut out, &node.id);
|
|
||||||
out.push(' ');
|
|
||||||
out.push_str(match node.state {
|
|
||||||
NodeState::Enabled => "enabled",
|
|
||||||
NodeState::Muted => "muted",
|
|
||||||
});
|
|
||||||
out.push_str("\n (executor ");
|
|
||||||
push_string(&mut out, &node.executor.key);
|
|
||||||
out.push_str(&format!(" {})\n (params ", node.executor.version));
|
|
||||||
push_json(&mut out, &node.parameters);
|
|
||||||
out.push_str(")\n (inputs");
|
|
||||||
for (name, references) in &node.inputs {
|
|
||||||
out.push_str("\n (input ");
|
|
||||||
push_string(&mut out, name);
|
|
||||||
for reference in references {
|
|
||||||
out.push_str(" (ref ");
|
|
||||||
push_string(&mut out, &reference.node);
|
|
||||||
out.push(' ');
|
|
||||||
push_string(&mut out, &reference.socket);
|
|
||||||
out.push(')');
|
|
||||||
}
|
|
||||||
out.push(')');
|
|
||||||
}
|
|
||||||
out.push_str(")\n )");
|
|
||||||
}
|
|
||||||
out.push_str("))\n");
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn validate_pipeline_declarations(graph: &Graph) -> Result<(), GraphError> {
|
|
||||||
let mut names = HashSet::new();
|
|
||||||
let mut shader_bytes = 0usize;
|
|
||||||
for RenderPipelineDeclaration {
|
|
||||||
name,
|
|
||||||
shader,
|
|
||||||
vertex_entry,
|
|
||||||
fragment_entry,
|
|
||||||
..
|
|
||||||
} in &graph.pipelines.render
|
|
||||||
{
|
|
||||||
if !names.insert(name) {
|
|
||||||
return Err(GraphError::new(
|
|
||||||
"GRAPH_DUPLICATE_ID",
|
|
||||||
format!("duplicate authored pipeline '{name}'"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for identifier in [name, vertex_entry, fragment_entry] {
|
|
||||||
if !super::identifier(identifier) || identifier.len() > 64 {
|
|
||||||
return Err(GraphError::new(
|
|
||||||
"GRAPH_INVALID_ID",
|
|
||||||
"invalid authored render pipeline identifier",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if super::contract(name).is_some_and(|contract| {
|
|
||||||
!contract.is_raster_draw()
|
|
||||||
&& contract.fullscreen_policy.is_none()
|
|
||||||
&& name != "frame_out"
|
|
||||||
}) {
|
|
||||||
return Err(GraphError::new(
|
|
||||||
"GRAPH_EXECUTION_UNSUPPORTED",
|
|
||||||
format!("authored render pipeline '{name}' conflicts with a core executor"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
shader_bytes = shader_bytes.saturating_add(shader.len());
|
|
||||||
}
|
|
||||||
for ComputePipelineDeclaration {
|
|
||||||
name,
|
|
||||||
shader,
|
|
||||||
entry,
|
|
||||||
dispatch,
|
|
||||||
} in &graph.pipelines.compute
|
|
||||||
{
|
|
||||||
if !names.insert(name) {
|
|
||||||
return Err(GraphError::new(
|
|
||||||
"GRAPH_DUPLICATE_ID",
|
|
||||||
format!("duplicate authored pipeline '{name}'"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for identifier in [name, entry] {
|
|
||||||
if !super::identifier(identifier) || identifier.len() > 64 {
|
|
||||||
return Err(GraphError::new(
|
|
||||||
"GRAPH_INVALID_ID",
|
|
||||||
"invalid authored compute pipeline identifier",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if dispatch.contains(&0) {
|
|
||||||
return Err(GraphError::new(
|
|
||||||
"GRAPH_PARAMETERS_INVALID",
|
|
||||||
"compute dispatch dimensions must be nonzero",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
shader_bytes = shader_bytes.saturating_add(shader.len());
|
|
||||||
}
|
|
||||||
if shader_bytes > MAX_AST_BYTES / 2 {
|
|
||||||
return Err(GraphError::new(
|
|
||||||
"GRAPH_LIMIT_EXCEEDED",
|
|
||||||
"authored shader source exceeds 512 KiB",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn canonical_round_trip_preserves_shared_dag_references() {
|
|
||||||
let graph = Graph {
|
|
||||||
schema_version: 3,
|
|
||||||
graph_id: "dag".into(),
|
|
||||||
revision: 7,
|
|
||||||
pipelines: PipelineDeclarations::default(),
|
|
||||||
nodes: vec![Node {
|
|
||||||
id: "consumer".into(),
|
|
||||||
state: NodeState::Enabled,
|
|
||||||
executor: ExecutorRef {
|
|
||||||
key: "and".into(),
|
|
||||||
version: 2,
|
|
||||||
},
|
|
||||||
parameters: serde_json::json!({}),
|
|
||||||
inputs: BTreeMap::from([(
|
|
||||||
"inputs".into(),
|
|
||||||
vec![
|
|
||||||
NodeOutputRef {
|
|
||||||
node: "shared".into(),
|
|
||||||
socket: "value".into(),
|
|
||||||
},
|
|
||||||
NodeOutputRef {
|
|
||||||
node: "shared".into(),
|
|
||||||
socket: "value".into(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)]),
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
let encoded = serialize(&graph);
|
|
||||||
let decoded = parse(encoded.as_bytes()).unwrap();
|
|
||||||
assert_eq!(decoded.graph_id, "dag");
|
|
||||||
assert_eq!(decoded.nodes[0].inputs["inputs"].len(), 2);
|
|
||||||
assert_eq!(serialize(&decoded), encoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_duplicate_fields_and_trailing_expressions() {
|
|
||||||
let duplicate = b"(yawn-graph 1 (id \"x\") (id \"y\") (revision 1) (pipelines (object (field \"render\" (array)) (field \"compute\" (array)))) (nodes))";
|
|
||||||
assert_eq!(parse(duplicate).unwrap_err().code, "GRAPH_AST_INVALID");
|
|
||||||
|
|
||||||
let trailing = b"(yawn-graph 1 (id \"x\") (revision 1) (pipelines (object (field \"render\" (array)) (field \"compute\" (array)))) (nodes)) true";
|
|
||||||
assert_eq!(parse(trailing).unwrap_err().code, "GRAPH_AST_INVALID");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_json_and_unknown_top_level_fields() {
|
|
||||||
assert_eq!(
|
|
||||||
parse(br#"{"graphId":"old"}"#).unwrap_err().code,
|
|
||||||
"GRAPH_AST_INVALID"
|
|
||||||
);
|
|
||||||
let source = b"(yawn-graph 1 (id \"x\") (revision 1) (pipelines (object (field \"render\" (array)) (field \"compute\" (array)))) (nodes) (legacy true))";
|
|
||||||
assert_eq!(parse(source).unwrap_err().code, "GRAPH_AST_INVALID");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,458 +0,0 @@
|
|||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum SemanticType {
|
|
||||||
MeshData,
|
|
||||||
Texture,
|
|
||||||
Bool,
|
|
||||||
F32,
|
|
||||||
U32,
|
|
||||||
Vec2,
|
|
||||||
Vec3,
|
|
||||||
Vec4,
|
|
||||||
Mat2,
|
|
||||||
Mat3,
|
|
||||||
Mat4,
|
|
||||||
U32x16,
|
|
||||||
LocalAabb,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SemanticType {
|
|
||||||
pub const fn is_virtual(self) -> bool {
|
|
||||||
!matches!(self, Self::MeshData | Self::Texture)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ExecutionClass {
|
|
||||||
Source,
|
|
||||||
Expression,
|
|
||||||
Render,
|
|
||||||
Frame,
|
|
||||||
}
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum FullscreenPolicy {
|
|
||||||
Copy,
|
|
||||||
HdrSameExtent,
|
|
||||||
BloomExtract,
|
|
||||||
BloomComposite,
|
|
||||||
}
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub struct InputCardinality {
|
|
||||||
pub min: u8,
|
|
||||||
pub max: u8,
|
|
||||||
}
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum InputDefaultPolicy {
|
|
||||||
None,
|
|
||||||
ParameterLiteral,
|
|
||||||
CompilerTexture,
|
|
||||||
}
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
|
||||||
#[serde(tag = "kind", content = "types", rename_all = "snake_case")]
|
|
||||||
pub enum TypeConstraint {
|
|
||||||
Exact(SemanticType),
|
|
||||||
OneOf(&'static [SemanticType]),
|
|
||||||
}
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
pub enum InputRole {
|
|
||||||
SemanticRead,
|
|
||||||
UniformRead,
|
|
||||||
StorageRead,
|
|
||||||
IndirectRead,
|
|
||||||
SampledTexture,
|
|
||||||
ColorTarget { location: u32 },
|
|
||||||
DepthTarget,
|
|
||||||
Expression,
|
|
||||||
}
|
|
||||||
#[derive(Clone, Copy, Debug, serde::Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct InputSocketContract {
|
|
||||||
pub name: &'static str,
|
|
||||||
pub accepted: TypeConstraint,
|
|
||||||
pub cardinality: InputCardinality,
|
|
||||||
pub default_policy: InputDefaultPolicy,
|
|
||||||
pub role: InputRole,
|
|
||||||
}
|
|
||||||
#[derive(Clone, Copy, Debug, serde::Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct OutputSocketContract {
|
|
||||||
pub name: &'static str,
|
|
||||||
pub semantic_type: SemanticType,
|
|
||||||
}
|
|
||||||
#[derive(Clone, Debug, serde::Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Contract {
|
|
||||||
pub key: &'static str,
|
|
||||||
pub version: u32,
|
|
||||||
pub execution: ExecutionClass,
|
|
||||||
pub inputs: &'static [InputSocketContract],
|
|
||||||
pub outputs: &'static [OutputSocketContract],
|
|
||||||
pub inherently_observable: bool,
|
|
||||||
#[serde(skip)]
|
|
||||||
pub fullscreen_policy: Option<FullscreenPolicy>,
|
|
||||||
}
|
|
||||||
impl Contract {
|
|
||||||
pub const fn is_raster_draw(&self) -> bool {
|
|
||||||
matches!(self.execution, ExecutionClass::Render) && self.fullscreen_policy.is_none()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
use SemanticType::*;
|
|
||||||
const R: InputCardinality = InputCardinality { min: 1, max: 1 };
|
|
||||||
const O: InputCardinality = InputCardinality { min: 0, max: 1 };
|
|
||||||
const V: InputCardinality = InputCardinality { min: 0, max: 8 };
|
|
||||||
const fn i(
|
|
||||||
name: &'static str,
|
|
||||||
ty: SemanticType,
|
|
||||||
cardinality: InputCardinality,
|
|
||||||
role: InputRole,
|
|
||||||
) -> InputSocketContract {
|
|
||||||
let default_policy = match (cardinality.min, cardinality.max) {
|
|
||||||
(_, 2..) => InputDefaultPolicy::None,
|
|
||||||
(1, _) => InputDefaultPolicy::None,
|
|
||||||
_ => match role {
|
|
||||||
InputRole::ColorTarget { .. } | InputRole::DepthTarget => {
|
|
||||||
InputDefaultPolicy::CompilerTexture
|
|
||||||
}
|
|
||||||
_ => InputDefaultPolicy::ParameterLiteral,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
InputSocketContract {
|
|
||||||
name,
|
|
||||||
accepted: TypeConstraint::Exact(ty),
|
|
||||||
cardinality,
|
|
||||||
default_policy,
|
|
||||||
role,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fn o(name: &'static str, semantic_type: SemanticType) -> OutputSocketContract {
|
|
||||||
OutputSocketContract {
|
|
||||||
name,
|
|
||||||
semantic_type,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const NONE_I: &[InputSocketContract] = &[];
|
|
||||||
const NONE_O: &[OutputSocketContract] = &[];
|
|
||||||
const MESH_O: &[OutputSocketContract] = &[
|
|
||||||
o("mesh", MeshData),
|
|
||||||
o("type", U32x16),
|
|
||||||
o("localAabb", LocalAabb),
|
|
||||||
];
|
|
||||||
const TEXTURE_O: &[OutputSocketContract] = &[o("texture", Texture)];
|
|
||||||
const RASTER_I: &[InputSocketContract] = &[
|
|
||||||
i("mesh", MeshData, R, InputRole::SemanticRead),
|
|
||||||
i("predicate", Bool, O, InputRole::Expression),
|
|
||||||
i("color", Texture, O, InputRole::ColorTarget { location: 0 }),
|
|
||||||
i("depth", Texture, O, InputRole::DepthTarget),
|
|
||||||
];
|
|
||||||
const RASTER_O: &[OutputSocketContract] = &[o("color", Texture), o("depth", Texture)];
|
|
||||||
const CULL_I: &[InputSocketContract] = &[
|
|
||||||
i("mesh", MeshData, R, InputRole::Expression),
|
|
||||||
i("localAabb", LocalAabb, R, InputRole::Expression),
|
|
||||||
];
|
|
||||||
const CULL_O: &[OutputSocketContract] = &[o("isFrustumCulled", Bool)];
|
|
||||||
const COPY_I: &[InputSocketContract] = &[
|
|
||||||
i("source", Texture, R, InputRole::SampledTexture),
|
|
||||||
i(
|
|
||||||
"colorTarget",
|
|
||||||
Texture,
|
|
||||||
R,
|
|
||||||
InputRole::ColorTarget { location: 0 },
|
|
||||||
),
|
|
||||||
];
|
|
||||||
const BLOOM_I: &[InputSocketContract] = &[
|
|
||||||
i("source", Texture, R, InputRole::SampledTexture),
|
|
||||||
i("bloom", Texture, R, InputRole::SampledTexture),
|
|
||||||
i(
|
|
||||||
"colorTarget",
|
|
||||||
Texture,
|
|
||||||
R,
|
|
||||||
InputRole::ColorTarget { location: 0 },
|
|
||||||
),
|
|
||||||
];
|
|
||||||
const COLOR_O: &[OutputSocketContract] = &[o("color", Texture)];
|
|
||||||
const FRAME_I: &[InputSocketContract] = &[i("color", Texture, R, InputRole::SampledTexture)];
|
|
||||||
macro_rules! ins { ($($n:literal:$t:ident),*) => { &[$(i($n,$t,O,InputRole::Expression)),*] } }
|
|
||||||
macro_rules! outs { ($($n:literal:$t:ident),*) => { &[$(o($n,$t)),*] } }
|
|
||||||
macro_rules! c {
|
|
||||||
($k:literal,$v:expr,$e:ident,$ins:expr,$outs:expr,$obs:expr,$policy:expr) => {
|
|
||||||
Contract {
|
|
||||||
key: $k,
|
|
||||||
version: $v,
|
|
||||||
execution: ExecutionClass::$e,
|
|
||||||
inputs: $ins,
|
|
||||||
outputs: $outs,
|
|
||||||
inherently_observable: $obs,
|
|
||||||
fullscreen_policy: $policy,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
macro_rules! ex {
|
|
||||||
($k:literal,$ins:expr,$outs:expr) => {
|
|
||||||
c!($k, 1, Expression, $ins, $outs, false, None)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const BOOL_VARIADIC_I: &[InputSocketContract] = &[i("inputs", Bool, V, InputRole::Expression)];
|
|
||||||
|
|
||||||
pub static CONTRACTS: &[Contract] = &[
|
|
||||||
c!("mesh", 2, Source, NONE_I, MESH_O, false, None),
|
|
||||||
c!("texture", 2, Source, NONE_I, TEXTURE_O, false, None),
|
|
||||||
c!("frustum_cull", 2, Expression, CULL_I, CULL_O, false, None),
|
|
||||||
c!(
|
|
||||||
"and",
|
|
||||||
2,
|
|
||||||
Expression,
|
|
||||||
BOOL_VARIADIC_I,
|
|
||||||
outs!("value":Bool),
|
|
||||||
false,
|
|
||||||
None
|
|
||||||
),
|
|
||||||
c!(
|
|
||||||
"or",
|
|
||||||
2,
|
|
||||||
Expression,
|
|
||||||
BOOL_VARIADIC_I,
|
|
||||||
outs!("value":Bool),
|
|
||||||
false,
|
|
||||||
None
|
|
||||||
),
|
|
||||||
ex!("not", ins!("operand":Bool), outs!("value":Bool)),
|
|
||||||
c!(
|
|
||||||
"xor",
|
|
||||||
2,
|
|
||||||
Expression,
|
|
||||||
BOOL_VARIADIC_I,
|
|
||||||
outs!("value":Bool),
|
|
||||||
false,
|
|
||||||
None
|
|
||||||
),
|
|
||||||
c!(
|
|
||||||
"xnor",
|
|
||||||
2,
|
|
||||||
Expression,
|
|
||||||
BOOL_VARIADIC_I,
|
|
||||||
outs!("value":Bool),
|
|
||||||
false,
|
|
||||||
None
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"greater_than_f32",
|
|
||||||
ins!("left":F32,"right":F32),
|
|
||||||
outs!("value":Bool)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"less_than_f32",
|
|
||||||
ins!("left":F32,"right":F32),
|
|
||||||
outs!("value":Bool)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"equals_f32",
|
|
||||||
ins!("left":F32,"right":F32),
|
|
||||||
outs!("value":Bool)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"greater_than_u32",
|
|
||||||
ins!("left":U32,"right":U32),
|
|
||||||
outs!("value":Bool)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"less_than_u32",
|
|
||||||
ins!("left":U32,"right":U32),
|
|
||||||
outs!("value":Bool)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"equals_u32",
|
|
||||||
ins!("left":U32,"right":U32),
|
|
||||||
outs!("value":Bool)
|
|
||||||
),
|
|
||||||
ex!("separate_vec2", ins!("vector":Vec2), outs!("x":F32,"y":F32)),
|
|
||||||
ex!("combine_vec2", ins!("x":F32,"y":F32), outs!("vector":Vec2)),
|
|
||||||
ex!(
|
|
||||||
"separate_vec3",
|
|
||||||
ins!("vector":Vec3),
|
|
||||||
outs!("x":F32,"y":F32,"z":F32)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"combine_vec3",
|
|
||||||
ins!("x":F32,"y":F32,"z":F32),
|
|
||||||
outs!("vector":Vec3)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"separate_vec4",
|
|
||||||
ins!("vector":Vec4),
|
|
||||||
outs!("x":F32,"y":F32,"z":F32,"w":F32)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"combine_vec4",
|
|
||||||
ins!("x":F32,"y":F32,"z":F32,"w":F32),
|
|
||||||
outs!("vector":Vec4)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"separate_mat2",
|
|
||||||
ins!("matrix":Mat2),
|
|
||||||
outs!("column0":Vec2,"column1":Vec2)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"combine_mat2",
|
|
||||||
ins!("column0":Vec2,"column1":Vec2),
|
|
||||||
outs!("matrix":Mat2)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"separate_mat3",
|
|
||||||
ins!("matrix":Mat3),
|
|
||||||
outs!("column0":Vec3,"column1":Vec3,"column2":Vec3)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"combine_mat3",
|
|
||||||
ins!("column0":Vec3,"column1":Vec3,"column2":Vec3),
|
|
||||||
outs!("matrix":Mat3)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"separate_mat4",
|
|
||||||
ins!("matrix":Mat4),
|
|
||||||
outs!("column0":Vec4,"column1":Vec4,"column2":Vec4,"column3":Vec4)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"combine_mat4",
|
|
||||||
ins!("column0":Vec4,"column1":Vec4,"column2":Vec4,"column3":Vec4),
|
|
||||||
outs!("matrix":Mat4)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"separate_u32x16",
|
|
||||||
ins!("value":U32x16),
|
|
||||||
outs!("word0":U32,"word1":U32,"word2":U32,"word3":U32,"word4":U32,"word5":U32,"word6":U32,"word7":U32,"word8":U32,"word9":U32,"word10":U32,"word11":U32,"word12":U32,"word13":U32,"word14":U32,"word15":U32)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"combine_u32x16",
|
|
||||||
ins!("word0":U32,"word1":U32,"word2":U32,"word3":U32,"word4":U32,"word5":U32,"word6":U32,"word7":U32,"word8":U32,"word9":U32,"word10":U32,"word11":U32,"word12":U32,"word13":U32,"word14":U32,"word15":U32),
|
|
||||||
outs!("value":U32x16)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"separate_u32_bits",
|
|
||||||
ins!("value":U32),
|
|
||||||
outs!("bit0":Bool,"bit1":Bool,"bit2":Bool,"bit3":Bool,"bit4":Bool,"bit5":Bool,"bit6":Bool,"bit7":Bool,"bit8":Bool,"bit9":Bool,"bit10":Bool,"bit11":Bool,"bit12":Bool,"bit13":Bool,"bit14":Bool,"bit15":Bool,"bit16":Bool,"bit17":Bool,"bit18":Bool,"bit19":Bool,"bit20":Bool,"bit21":Bool,"bit22":Bool,"bit23":Bool,"bit24":Bool,"bit25":Bool,"bit26":Bool,"bit27":Bool,"bit28":Bool,"bit29":Bool,"bit30":Bool,"bit31":Bool)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"combine_u32_bits",
|
|
||||||
ins!("bit0":Bool,"bit1":Bool,"bit2":Bool,"bit3":Bool,"bit4":Bool,"bit5":Bool,"bit6":Bool,"bit7":Bool,"bit8":Bool,"bit9":Bool,"bit10":Bool,"bit11":Bool,"bit12":Bool,"bit13":Bool,"bit14":Bool,"bit15":Bool,"bit16":Bool,"bit17":Bool,"bit18":Bool,"bit19":Bool,"bit20":Bool,"bit21":Bool,"bit22":Bool,"bit23":Bool,"bit24":Bool,"bit25":Bool,"bit26":Bool,"bit27":Bool,"bit28":Bool,"bit29":Bool,"bit30":Bool,"bit31":Bool),
|
|
||||||
outs!("value":U32)
|
|
||||||
),
|
|
||||||
ex!(
|
|
||||||
"separate_local_aabb",
|
|
||||||
ins!("value":LocalAabb),
|
|
||||||
outs!("min":Vec3,"max":Vec3)
|
|
||||||
),
|
|
||||||
c!(
|
|
||||||
"fullscreen_copy",
|
|
||||||
1,
|
|
||||||
Render,
|
|
||||||
COPY_I,
|
|
||||||
COLOR_O,
|
|
||||||
false,
|
|
||||||
Some(FullscreenPolicy::Copy)
|
|
||||||
),
|
|
||||||
c!(
|
|
||||||
"color_balance",
|
|
||||||
1,
|
|
||||||
Render,
|
|
||||||
COPY_I,
|
|
||||||
COLOR_O,
|
|
||||||
false,
|
|
||||||
Some(FullscreenPolicy::HdrSameExtent)
|
|
||||||
),
|
|
||||||
c!(
|
|
||||||
"exposure_contrast",
|
|
||||||
1,
|
|
||||||
Render,
|
|
||||||
COPY_I,
|
|
||||||
COLOR_O,
|
|
||||||
false,
|
|
||||||
Some(FullscreenPolicy::HdrSameExtent)
|
|
||||||
),
|
|
||||||
c!(
|
|
||||||
"saturation",
|
|
||||||
1,
|
|
||||||
Render,
|
|
||||||
COPY_I,
|
|
||||||
COLOR_O,
|
|
||||||
false,
|
|
||||||
Some(FullscreenPolicy::HdrSameExtent)
|
|
||||||
),
|
|
||||||
c!(
|
|
||||||
"channel_mixer",
|
|
||||||
1,
|
|
||||||
Render,
|
|
||||||
COPY_I,
|
|
||||||
COLOR_O,
|
|
||||||
false,
|
|
||||||
Some(FullscreenPolicy::HdrSameExtent)
|
|
||||||
),
|
|
||||||
c!(
|
|
||||||
"bloom_extract",
|
|
||||||
1,
|
|
||||||
Render,
|
|
||||||
COPY_I,
|
|
||||||
COLOR_O,
|
|
||||||
false,
|
|
||||||
Some(FullscreenPolicy::BloomExtract)
|
|
||||||
),
|
|
||||||
c!(
|
|
||||||
"bloom_blur",
|
|
||||||
1,
|
|
||||||
Render,
|
|
||||||
COPY_I,
|
|
||||||
COLOR_O,
|
|
||||||
false,
|
|
||||||
Some(FullscreenPolicy::HdrSameExtent)
|
|
||||||
),
|
|
||||||
c!(
|
|
||||||
"bloom_composite",
|
|
||||||
1,
|
|
||||||
Render,
|
|
||||||
BLOOM_I,
|
|
||||||
COLOR_O,
|
|
||||||
false,
|
|
||||||
Some(FullscreenPolicy::BloomComposite)
|
|
||||||
),
|
|
||||||
c!(
|
|
||||||
"luminance_edge",
|
|
||||||
1,
|
|
||||||
Render,
|
|
||||||
COPY_I,
|
|
||||||
COLOR_O,
|
|
||||||
false,
|
|
||||||
Some(FullscreenPolicy::HdrSameExtent)
|
|
||||||
),
|
|
||||||
c!("frame_out", 3, Frame, FRAME_I, NONE_O, true, None),
|
|
||||||
];
|
|
||||||
pub fn contract(key: &str) -> Option<&'static Contract> {
|
|
||||||
CONTRACTS.iter().find(|c| c.key == key)
|
|
||||||
}
|
|
||||||
|
|
||||||
static AUTHORED_RENDER_PIPELINE: Contract = c!(
|
|
||||||
"authored_render_pipeline",
|
|
||||||
2,
|
|
||||||
Render,
|
|
||||||
RASTER_I,
|
|
||||||
RASTER_O,
|
|
||||||
false,
|
|
||||||
None
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Resolve static core executors or a render pipeline declared by this graph.
|
|
||||||
pub fn contract_for(
|
|
||||||
key: &str,
|
|
||||||
pipelines: &crate::render_graph::PipelineDeclarations,
|
|
||||||
) -> Option<&'static Contract> {
|
|
||||||
contract(key).or_else(|| {
|
|
||||||
pipelines
|
|
||||||
.render
|
|
||||||
.iter()
|
|
||||||
.any(|pipeline| pipeline.name == key)
|
|
||||||
.then_some(&AUTHORED_RENDER_PIPELINE)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
//! Typed, device-independent instance predicate IR.
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use super::{NodeOutputRef, SemanticType};
|
|
||||||
|
|
||||||
pub const MAX_EXPRESSIONS: usize = 4096;
|
|
||||||
pub const MAX_PREDICATE_PIPELINES: usize = 64;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
|
||||||
#[serde(transparent)]
|
|
||||||
pub struct ExprId(pub u32);
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
||||||
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
|
|
||||||
pub enum TypedLiteral {
|
|
||||||
Bool(bool),
|
|
||||||
F32(f32),
|
|
||||||
U32(u32),
|
|
||||||
Vec2([f32; 2]),
|
|
||||||
Vec3([f32; 3]),
|
|
||||||
Vec4([f32; 4]),
|
|
||||||
Mat2([[f32; 2]; 2]),
|
|
||||||
Mat3([[f32; 3]; 3]),
|
|
||||||
Mat4([[f32; 4]; 4]),
|
|
||||||
U32x16([u32; 16]),
|
|
||||||
LocalAabb { min: [f32; 3], max: [f32; 3] },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TypedLiteral {
|
|
||||||
pub fn semantic_type(&self) -> SemanticType {
|
|
||||||
match self {
|
|
||||||
Self::Bool(_) => SemanticType::Bool,
|
|
||||||
Self::F32(_) => SemanticType::F32,
|
|
||||||
Self::U32(_) => SemanticType::U32,
|
|
||||||
Self::Vec2(_) => SemanticType::Vec2,
|
|
||||||
Self::Vec3(_) => SemanticType::Vec3,
|
|
||||||
Self::Vec4(_) => SemanticType::Vec4,
|
|
||||||
Self::Mat2(_) => SemanticType::Mat2,
|
|
||||||
Self::Mat3(_) => SemanticType::Mat3,
|
|
||||||
Self::Mat4(_) => SemanticType::Mat4,
|
|
||||||
Self::U32x16(_) => SemanticType::U32x16,
|
|
||||||
Self::LocalAabb { .. } => SemanticType::LocalAabb,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_finite(&self) -> bool {
|
|
||||||
let finite = |values: &[f32]| values.iter().all(|value| value.is_finite());
|
|
||||||
match self {
|
|
||||||
Self::F32(value) => value.is_finite(),
|
|
||||||
Self::Vec2(value) => finite(value),
|
|
||||||
Self::Vec3(value) => finite(value),
|
|
||||||
Self::Vec4(value) => finite(value),
|
|
||||||
Self::Mat2(value) => value.iter().all(|column| finite(column)),
|
|
||||||
Self::Mat3(value) => value.iter().all(|column| finite(column)),
|
|
||||||
Self::Mat4(value) => value.iter().all(|column| finite(column)),
|
|
||||||
Self::LocalAabb { min, max } => finite(min) && finite(max),
|
|
||||||
_ => true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum CompareOp {
|
|
||||||
GreaterThan,
|
|
||||||
LessThan,
|
|
||||||
Equals,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum BooleanOp {
|
|
||||||
And,
|
|
||||||
Or,
|
|
||||||
Xor,
|
|
||||||
Xnor,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All operand IDs refer to earlier entries in [`ExpressionPlan::expressions`].
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
||||||
#[serde(tag = "op", rename_all = "snake_case")]
|
|
||||||
pub enum ExpressionOp {
|
|
||||||
Literal {
|
|
||||||
literal: TypedLiteral,
|
|
||||||
},
|
|
||||||
InstanceType {
|
|
||||||
mesh: u32,
|
|
||||||
},
|
|
||||||
LocalAabb {
|
|
||||||
mesh: u32,
|
|
||||||
},
|
|
||||||
Not {
|
|
||||||
value: ExprId,
|
|
||||||
},
|
|
||||||
Boolean {
|
|
||||||
operation: BooleanOp,
|
|
||||||
operands: Vec<ExprId>,
|
|
||||||
},
|
|
||||||
CompareF32 {
|
|
||||||
operation: CompareOp,
|
|
||||||
left: ExprId,
|
|
||||||
right: ExprId,
|
|
||||||
},
|
|
||||||
CompareU32 {
|
|
||||||
operation: CompareOp,
|
|
||||||
left: ExprId,
|
|
||||||
right: ExprId,
|
|
||||||
},
|
|
||||||
VectorProject {
|
|
||||||
vector: ExprId,
|
|
||||||
index: u8,
|
|
||||||
},
|
|
||||||
VectorConstruct {
|
|
||||||
components: Vec<ExprId>,
|
|
||||||
},
|
|
||||||
MatrixColumn {
|
|
||||||
matrix: ExprId,
|
|
||||||
index: u8,
|
|
||||||
},
|
|
||||||
MatrixConstruct {
|
|
||||||
columns: Vec<ExprId>,
|
|
||||||
},
|
|
||||||
TypeWord {
|
|
||||||
value: ExprId,
|
|
||||||
index: u8,
|
|
||||||
},
|
|
||||||
TypeConstruct {
|
|
||||||
words: Vec<ExprId>,
|
|
||||||
},
|
|
||||||
U32Bit {
|
|
||||||
value: ExprId,
|
|
||||||
index: u8,
|
|
||||||
},
|
|
||||||
U32Construct {
|
|
||||||
bits: Vec<ExprId>,
|
|
||||||
},
|
|
||||||
AabbMin {
|
|
||||||
aabb: ExprId,
|
|
||||||
},
|
|
||||||
AabbMax {
|
|
||||||
aabb: ExprId,
|
|
||||||
},
|
|
||||||
FrustumCulled {
|
|
||||||
mesh: u32,
|
|
||||||
local_aabb: ExprId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Expression {
|
|
||||||
pub semantic_type: SemanticType,
|
|
||||||
pub op: ExpressionOp,
|
|
||||||
pub origin: NodeOutputRef,
|
|
||||||
pub mesh_provenance: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ExpressionPlan {
|
|
||||||
pub expressions: Vec<Expression>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PipelinePredicatePlan {
|
|
||||||
pub execution: u32,
|
|
||||||
pub predicate: ExprId,
|
|
||||||
pub ordinal: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct InstanceTraversalPlan {
|
|
||||||
pub mesh: u32,
|
|
||||||
pub expressions: ExpressionPlan,
|
|
||||||
pub pipelines: Vec<PipelinePredicatePlan>,
|
|
||||||
pub requires_camera: bool,
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
//! Device-free render graph AST, compiler, and compiled graph registry.
|
|
||||||
|
|
||||||
mod ast;
|
|
||||||
mod compiler;
|
|
||||||
pub(crate) use compiler::execution_attachments;
|
|
||||||
mod contracts;
|
|
||||||
mod expression;
|
|
||||||
mod plan;
|
|
||||||
mod registry;
|
|
||||||
mod runtime;
|
|
||||||
mod schema;
|
|
||||||
|
|
||||||
pub use compiler::{compile, parse_and_compile};
|
|
||||||
pub use contracts::*;
|
|
||||||
pub use expression::*;
|
|
||||||
pub use plan::*;
|
|
||||||
pub use registry::{CompiledGraphId, Registry};
|
|
||||||
pub use runtime::*;
|
|
||||||
pub use schema::*;
|
|
||||||
|
|
||||||
pub const MAX_AST_BYTES: usize = 1024 * 1024;
|
|
||||||
pub const MAX_EXECUTIONS: usize = 1024;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
|
|
||||||
pub struct GraphError {
|
|
||||||
pub code: &'static str,
|
|
||||||
pub message: String,
|
|
||||||
pub details: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GraphError {
|
|
||||||
pub(crate) fn new(code: &'static str, message: impl Into<String>) -> Self {
|
|
||||||
let message = message.into();
|
|
||||||
Self {
|
|
||||||
code,
|
|
||||||
details: serde_json::json!({"message": message}),
|
|
||||||
message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn at(
|
|
||||||
code: &'static str,
|
|
||||||
message: impl Into<String>,
|
|
||||||
path: impl Into<String>,
|
|
||||||
) -> Self {
|
|
||||||
let message = message.into();
|
|
||||||
Self {
|
|
||||||
code,
|
|
||||||
details: serde_json::json!({"message": message, "path": path.into()}),
|
|
||||||
message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) mod tests;
|
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct CompiledGraph {
|
|
||||||
pub schema_version: u32,
|
|
||||||
pub graph_id: String,
|
|
||||||
pub revision: u32,
|
|
||||||
pub node_count: u32,
|
|
||||||
pub pipelines: PipelineDeclarations,
|
|
||||||
pub resources: Vec<CompiledResource>,
|
|
||||||
pub executions: Vec<CompiledExecution>,
|
|
||||||
pub render_passes: Vec<PhysicalRenderPass>,
|
|
||||||
pub texture_families: Vec<TextureFamily>,
|
|
||||||
pub allocation_classes: Vec<AllocationClass>,
|
|
||||||
pub culled_node_count: u32,
|
|
||||||
pub culled_resource_count: u32,
|
|
||||||
pub transient_slot_count: u32,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub instance_traversal: Option<InstanceTraversalPlan>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct CompiledResource {
|
|
||||||
pub original_node_index: u32,
|
|
||||||
pub origin: ResourceOrigin,
|
|
||||||
pub semantic_type: SemanticType,
|
|
||||||
pub producer_execution: Option<u32>,
|
|
||||||
pub lifetime: Option<Lifetime>,
|
|
||||||
pub plan: ResourcePlan,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
pub enum ResourceOrigin {
|
|
||||||
AuthoredOutput {
|
|
||||||
node: String,
|
|
||||||
socket: String,
|
|
||||||
output_ordinal: u16,
|
|
||||||
},
|
|
||||||
CompilerDefaultInput {
|
|
||||||
owner_node_index: u32,
|
|
||||||
input_ordinal: u16,
|
|
||||||
socket: String,
|
|
||||||
role: CompilerTextureRole,
|
|
||||||
},
|
|
||||||
CompilerColorResolve {
|
|
||||||
producer_node_index: u32,
|
|
||||||
output_ordinal: u16,
|
|
||||||
source_resource: u32,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum CompilerTextureRole {
|
|
||||||
ColorTarget,
|
|
||||||
DepthTarget,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
pub enum ResourcePlan {
|
|
||||||
TextureSource {
|
|
||||||
family: u32,
|
|
||||||
residency: TextureResidency,
|
|
||||||
descriptor: NormalizedTextureDescriptor,
|
|
||||||
},
|
|
||||||
Texture {
|
|
||||||
family: u32,
|
|
||||||
version: u32,
|
|
||||||
target: u32,
|
|
||||||
initialized: bool,
|
|
||||||
stored: bool,
|
|
||||||
allocation: Option<AllocationRef>,
|
|
||||||
},
|
|
||||||
MeshData,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct CompiledExecution {
|
|
||||||
pub id: String,
|
|
||||||
pub original_node_index: u32,
|
|
||||||
pub executor: ExecutorRef,
|
|
||||||
pub parameters: NormalizedParameters,
|
|
||||||
pub kind: ExecutionKind,
|
|
||||||
pub inputs: Vec<CompiledSocketInput>,
|
|
||||||
pub outputs: Vec<CompiledSocketOutput>,
|
|
||||||
pub accesses: Vec<CompiledAccess>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct CompiledSocketInput {
|
|
||||||
pub socket: String,
|
|
||||||
pub resource: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct CompiledSocketOutput {
|
|
||||||
pub socket: String,
|
|
||||||
pub resource: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
pub enum ExecutionKind {
|
|
||||||
RasterDraw,
|
|
||||||
Fullscreen,
|
|
||||||
FrameOut { color: u32 },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PhysicalRenderPass {
|
|
||||||
pub executions: Vec<u32>,
|
|
||||||
pub kind: PhysicalRenderPassKind,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
pub enum PhysicalRenderPassKind {
|
|
||||||
Texture {
|
|
||||||
color_attachments: Vec<ColorAttachmentPlan>,
|
|
||||||
depth_stencil: Option<DepthStencilAttachmentPlan>,
|
|
||||||
},
|
|
||||||
Surface,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ColorAttachmentPlan {
|
|
||||||
pub resource: u32,
|
|
||||||
pub resolve_target: Option<u32>,
|
|
||||||
pub location: u32,
|
|
||||||
pub load: NormalizedColorLoad,
|
|
||||||
pub store: StoreOp,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DepthStencilAttachmentPlan {
|
|
||||||
pub resource: u32,
|
|
||||||
pub load: NormalizedDepthLoad,
|
|
||||||
pub store: StoreOp,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
pub enum NormalizedColorLoad {
|
|
||||||
Load,
|
|
||||||
Clear { value: [f64; 4] },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
pub enum NormalizedDepthLoad {
|
|
||||||
Load,
|
|
||||||
Clear { value: f32 },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum StoreOp {
|
|
||||||
Store,
|
|
||||||
Discard,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct CompiledAccess {
|
|
||||||
pub socket: String,
|
|
||||||
pub resource: u32,
|
|
||||||
pub mode: AccessMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
pub enum AccessMode {
|
|
||||||
SemanticRead,
|
|
||||||
UniformRead,
|
|
||||||
StorageRead,
|
|
||||||
StorageWrite {
|
|
||||||
full_overwrite: bool,
|
|
||||||
},
|
|
||||||
IndirectRead,
|
|
||||||
SampledTexture,
|
|
||||||
ColorResolve {
|
|
||||||
source: u32,
|
|
||||||
location: u32,
|
|
||||||
},
|
|
||||||
ColorAttachment {
|
|
||||||
location: u32,
|
|
||||||
load: NormalizedColorLoad,
|
|
||||||
store: StoreOp,
|
|
||||||
full_overwrite: bool,
|
|
||||||
},
|
|
||||||
DepthAttachment {
|
|
||||||
load: NormalizedDepthLoad,
|
|
||||||
store: StoreOp,
|
|
||||||
full_overwrite: bool,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
pub enum NormalizedParameters {
|
|
||||||
Texture {
|
|
||||||
residency: TextureResidency,
|
|
||||||
descriptor: NormalizedTextureDescriptor,
|
|
||||||
},
|
|
||||||
Mesh,
|
|
||||||
FrustumCull {
|
|
||||||
camera: ActiveCamera,
|
|
||||||
},
|
|
||||||
ExpressionDefaults {
|
|
||||||
defaults: Vec<TypedLiteral>,
|
|
||||||
},
|
|
||||||
Raster {
|
|
||||||
depth_compare: CompareFunction,
|
|
||||||
depth_write_enabled: bool,
|
|
||||||
clear_depth: f32,
|
|
||||||
clear_color: [f64; 4],
|
|
||||||
predicate_default: bool,
|
|
||||||
},
|
|
||||||
FullscreenCopy,
|
|
||||||
ColorBalance {
|
|
||||||
mode: ColorBalanceMode,
|
|
||||||
factor: f32,
|
|
||||||
lift: f32,
|
|
||||||
lift_color: [f32; 3],
|
|
||||||
gamma: f32,
|
|
||||||
gamma_color: [f32; 3],
|
|
||||||
gain: f32,
|
|
||||||
gain_color: [f32; 3],
|
|
||||||
offset: f32,
|
|
||||||
offset_color: [f32; 3],
|
|
||||||
power: f32,
|
|
||||||
power_color: [f32; 3],
|
|
||||||
slope: f32,
|
|
||||||
slope_color: [f32; 3],
|
|
||||||
},
|
|
||||||
ExposureContrast {
|
|
||||||
exposure_stops: f32,
|
|
||||||
contrast: f32,
|
|
||||||
pivot: f32,
|
|
||||||
factor: f32,
|
|
||||||
},
|
|
||||||
Saturation {
|
|
||||||
saturation: f32,
|
|
||||||
factor: f32,
|
|
||||||
},
|
|
||||||
ChannelMixer {
|
|
||||||
red_output: [f32; 3],
|
|
||||||
green_output: [f32; 3],
|
|
||||||
blue_output: [f32; 3],
|
|
||||||
factor: f32,
|
|
||||||
},
|
|
||||||
BloomExtract {
|
|
||||||
threshold: f32,
|
|
||||||
knee: f32,
|
|
||||||
},
|
|
||||||
BloomBlur {
|
|
||||||
direction: [f32; 2],
|
|
||||||
radius: f32,
|
|
||||||
},
|
|
||||||
BloomComposite {
|
|
||||||
intensity: f32,
|
|
||||||
},
|
|
||||||
LuminanceEdge {
|
|
||||||
strength: f32,
|
|
||||||
},
|
|
||||||
FrameOut {
|
|
||||||
surface_format: SurfaceFormatRequest,
|
|
||||||
dynamic_range: FrameDynamicRange,
|
|
||||||
output_transfer: OutputTransfer,
|
|
||||||
scale_mode: ScaleMode,
|
|
||||||
filter: FrameFilter,
|
|
||||||
background_color: [f32; 4],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum SurfaceFormatRequest {
|
|
||||||
Preferred,
|
|
||||||
Rgba8Unorm,
|
|
||||||
Bgra8Unorm,
|
|
||||||
Rgba16Float,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, serde::Deserialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
pub enum FrameDynamicRange {
|
|
||||||
Sdr,
|
|
||||||
Hdr {
|
|
||||||
tone_mapper: ToneMapper,
|
|
||||||
exposure_stops: f32,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ToneMapper {
|
|
||||||
Aces,
|
|
||||||
Reinhard,
|
|
||||||
None,
|
|
||||||
}
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum OutputTransfer {
|
|
||||||
Srgb,
|
|
||||||
Linear,
|
|
||||||
}
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ScaleMode {
|
|
||||||
Stretch,
|
|
||||||
Contain,
|
|
||||||
Cover,
|
|
||||||
}
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum FrameFilter {
|
|
||||||
Linear,
|
|
||||||
Nearest,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ColorBalanceMode {
|
|
||||||
LiftGammaGain,
|
|
||||||
OffsetPowerSlope,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ActiveCamera {
|
|
||||||
Active,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct NormalizedTextureDescriptor {
|
|
||||||
pub dimension: TextureDimension,
|
|
||||||
pub format: TextureFormat,
|
|
||||||
pub extent: NormalizedTextureExtent,
|
|
||||||
pub mip_level_count: u32,
|
|
||||||
pub sample_count: u32,
|
|
||||||
pub view_formats: Vec<TextureFormat>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
pub enum NormalizedTextureExtent {
|
|
||||||
Absolute {
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
depth_or_array_layers: u32,
|
|
||||||
},
|
|
||||||
SurfaceRelative {
|
|
||||||
width: Ratio,
|
|
||||||
height: Ratio,
|
|
||||||
depth_or_array_layers: u32,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Lifetime {
|
|
||||||
pub first_use: u32,
|
|
||||||
pub last_use: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct TextureFamilyKey {
|
|
||||||
pub source_node: u32,
|
|
||||||
pub source_socket: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
pub enum TextureFamilySource {
|
|
||||||
AuthoredTexture {
|
|
||||||
resource: u32,
|
|
||||||
residency: TextureResidency,
|
|
||||||
descriptor: NormalizedTextureDescriptor,
|
|
||||||
},
|
|
||||||
CompilerDefaultInput {
|
|
||||||
resource: u32,
|
|
||||||
owner_node_index: u32,
|
|
||||||
input_ordinal: u16,
|
|
||||||
role: CompilerTextureRole,
|
|
||||||
descriptor: NormalizedTextureDescriptor,
|
|
||||||
},
|
|
||||||
CompilerColorResolve {
|
|
||||||
resource: u32,
|
|
||||||
descriptor: NormalizedTextureDescriptor,
|
|
||||||
producer_node_index: u32,
|
|
||||||
output_ordinal: u16,
|
|
||||||
source_resource: u32,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct TextureFamily {
|
|
||||||
pub id: u32,
|
|
||||||
pub key: TextureFamilyKey,
|
|
||||||
pub source: TextureFamilySource,
|
|
||||||
pub lifetime: Lifetime,
|
|
||||||
pub versions: Vec<TextureVersion>,
|
|
||||||
pub usage: Vec<TextureUsage>,
|
|
||||||
pub allocation: Option<AllocationRef>,
|
|
||||||
pub aliasable: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct TextureVersion {
|
|
||||||
pub version: u32,
|
|
||||||
pub resource: u32,
|
|
||||||
pub target: u32,
|
|
||||||
pub initialized: bool,
|
|
||||||
pub stored: bool,
|
|
||||||
pub lifetime: Lifetime,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct TextureCompatibilityKey {
|
|
||||||
pub dimension: TextureDimension,
|
|
||||||
pub format: TextureFormat,
|
|
||||||
pub extent: NormalizedTextureExtent,
|
|
||||||
pub mip_level_count: u32,
|
|
||||||
pub sample_count: u32,
|
|
||||||
pub view_formats: Vec<TextureFormat>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct AllocationClass {
|
|
||||||
pub key: TextureCompatibilityKey,
|
|
||||||
pub slots: Vec<AllocationSlot>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum AllocationKind {
|
|
||||||
AliasedTransient,
|
|
||||||
DedicatedTransient,
|
|
||||||
Persistent,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct AllocationSlot {
|
|
||||||
pub kind: AllocationKind,
|
|
||||||
pub usage: Vec<TextureUsage>,
|
|
||||||
pub occupants: Vec<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct AllocationRef {
|
|
||||||
pub class: u32,
|
|
||||||
pub slot: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TextureUsage {
|
|
||||||
Sampled,
|
|
||||||
Storage,
|
|
||||||
CopySrc,
|
|
||||||
CopyDst,
|
|
||||||
ColorAttachment,
|
|
||||||
DepthAttachment,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CompiledGraph {
|
|
||||||
pub fn summary(&self, id: [u32; 2]) -> serde_json::Value {
|
|
||||||
serde_json::json!({"compiledId":id,"graphId":self.graph_id,"revision":self.revision,"schemaVersion":self.schema_version,"nodeCount":self.node_count,"executionCount":self.executions.len(),"computePassCount":self.pipelines.compute.len(),"physicalPassCount":self.render_passes.len(),"resourceCount":self.resources.len(),"culledNodeCount":self.culled_node_count,"culledResourceCount":self.culled_resource_count,"transientSlotCount":self.transient_slot_count})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use super::{parse_and_compile, CompiledGraph, GraphError};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct CompiledGraphId {
|
|
||||||
pub slot: u32,
|
|
||||||
pub generation: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<CompiledGraphId> for [u32; 2] {
|
|
||||||
fn from(id: CompiledGraphId) -> Self {
|
|
||||||
[id.slot, id.generation]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct Slot {
|
|
||||||
generation: u32,
|
|
||||||
value: Option<CompiledGraph>,
|
|
||||||
retired: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Registry {
|
|
||||||
slots: Vec<Slot>,
|
|
||||||
capacity: u32,
|
|
||||||
latest_revisions: HashMap<String, u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Registry {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new(16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Registry {
|
|
||||||
pub fn new(capacity: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
slots: vec![],
|
|
||||||
capacity,
|
|
||||||
latest_revisions: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn compile(
|
|
||||||
&mut self,
|
|
||||||
bytes: &[u8],
|
|
||||||
) -> Result<(CompiledGraphId, serde_json::Value), GraphError> {
|
|
||||||
let graph = parse_and_compile(bytes)?;
|
|
||||||
if self
|
|
||||||
.latest_revisions
|
|
||||||
.get(&graph.graph_id)
|
|
||||||
.is_some_and(|latest| graph.revision <= *latest)
|
|
||||||
{
|
|
||||||
return Err(GraphError::new(
|
|
||||||
"GRAPH_REVISION_CONFLICT",
|
|
||||||
"revision must increase",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let index = if let Some(index) = self
|
|
||||||
.slots
|
|
||||||
.iter()
|
|
||||||
.position(|slot| slot.value.is_none() && !slot.retired)
|
|
||||||
{
|
|
||||||
index
|
|
||||||
} else {
|
|
||||||
if u32::try_from(self.slots.len()).map_or(true, |len| len >= self.capacity) {
|
|
||||||
return Err(GraphError::new(
|
|
||||||
"GRAPH_REGISTRY_FULL",
|
|
||||||
"compiled graph registry is full",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.slots.push(Slot {
|
|
||||||
generation: 1,
|
|
||||||
value: None,
|
|
||||||
retired: false,
|
|
||||||
});
|
|
||||||
self.slots.len() - 1
|
|
||||||
};
|
|
||||||
let id = CompiledGraphId {
|
|
||||||
slot: u32::try_from(index)
|
|
||||||
.map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "registry slot overflow"))?,
|
|
||||||
generation: self.slots[index].generation,
|
|
||||||
};
|
|
||||||
let summary = graph.summary(id.into());
|
|
||||||
self.latest_revisions
|
|
||||||
.insert(graph.graph_id.clone(), graph.revision);
|
|
||||||
self.slots[index].value = Some(graph);
|
|
||||||
Ok((id, summary))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, id: CompiledGraphId) -> Result<&CompiledGraph, GraphError> {
|
|
||||||
self.slots
|
|
||||||
.get(id.slot as usize)
|
|
||||||
.filter(|slot| slot.generation == id.generation)
|
|
||||||
.and_then(|slot| slot.value.as_ref())
|
|
||||||
.ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn contains(&self, id: CompiledGraphId) -> bool {
|
|
||||||
self.get(id).is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn drop_graph(&mut self, id: CompiledGraphId) -> Result<(), GraphError> {
|
|
||||||
let slot = self
|
|
||||||
.slots
|
|
||||||
.get_mut(id.slot as usize)
|
|
||||||
.filter(|slot| slot.generation == id.generation && slot.value.is_some())
|
|
||||||
.ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))?;
|
|
||||||
slot.value = None;
|
|
||||||
if slot.generation == u32::MAX {
|
|
||||||
slot.retired = true
|
|
||||||
} else {
|
|
||||||
slot.generation += 1
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,165 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
|
||||||
pub struct Graph {
|
|
||||||
pub schema_version: u32,
|
|
||||||
pub graph_id: String,
|
|
||||||
pub revision: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub pipelines: PipelineDeclarations,
|
|
||||||
pub nodes: Vec<Node>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct Node {
|
|
||||||
pub id: String,
|
|
||||||
pub state: NodeState,
|
|
||||||
pub executor: ExecutorRef,
|
|
||||||
pub parameters: serde_json::Value,
|
|
||||||
pub inputs: BTreeMap<String, Vec<NodeOutputRef>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GPU programs shipped with a graph AST and prepared with the graph loadout.
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
|
||||||
pub struct PipelineDeclarations {
|
|
||||||
#[serde(default)]
|
|
||||||
pub render: Vec<RenderPipelineDeclaration>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub compute: Vec<ComputePipelineDeclaration>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A scene render pipeline using Yawn's fixed mesh/instance SOA vertex layout.
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
|
||||||
pub struct RenderPipelineDeclaration {
|
|
||||||
pub name: String,
|
|
||||||
pub shader: String,
|
|
||||||
pub vertex_entry: String,
|
|
||||||
pub fragment_entry: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub double_sided: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub material: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A binding-free compute pass dispatched before the graph's render passes.
|
|
||||||
///
|
|
||||||
/// Bindings are deliberately not implicit: shared SOA bindings will be added as an
|
|
||||||
/// explicit AST resource contract rather than inferred from shader source.
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
|
||||||
pub struct ComputePipelineDeclaration {
|
|
||||||
pub name: String,
|
|
||||||
pub shader: String,
|
|
||||||
pub entry: String,
|
|
||||||
pub dispatch: [u32; 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum NodeState {
|
|
||||||
Enabled,
|
|
||||||
Muted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct ExecutorRef {
|
|
||||||
pub key: String,
|
|
||||||
pub version: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct NodeOutputRef {
|
|
||||||
pub node: String,
|
|
||||||
pub socket: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TextureDimension {
|
|
||||||
D1,
|
|
||||||
D2,
|
|
||||||
D3,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TextureFormat {
|
|
||||||
Rgba8Unorm,
|
|
||||||
Rgba8UnormSrgb,
|
|
||||||
Bgra8Unorm,
|
|
||||||
Bgra8UnormSrgb,
|
|
||||||
Rgba16Float,
|
|
||||||
R32Float,
|
|
||||||
Depth32Float,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
|
||||||
pub enum TextureExtent {
|
|
||||||
Absolute {
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
#[serde(rename = "depthOrArrayLayers")]
|
|
||||||
depth_or_array_layers: u32,
|
|
||||||
},
|
|
||||||
SurfaceRelative {
|
|
||||||
width: Ratio,
|
|
||||||
height: Ratio,
|
|
||||||
#[serde(rename = "depthOrArrayLayers")]
|
|
||||||
depth_or_array_layers: u32,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct Ratio {
|
|
||||||
pub numerator: u32,
|
|
||||||
pub denominator: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TextureResidency {
|
|
||||||
Transient,
|
|
||||||
Persistent,
|
|
||||||
History,
|
|
||||||
Readback,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
||||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
|
||||||
pub struct TextureDescriptor {
|
|
||||||
pub dimension: TextureDimension,
|
|
||||||
pub format: TextureFormat,
|
|
||||||
pub extent: TextureExtent,
|
|
||||||
pub mip_level_count: u32,
|
|
||||||
pub sample_count: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub view_formats: Vec<TextureFormat>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum CompareFunction {
|
|
||||||
Never,
|
|
||||||
Less,
|
|
||||||
Equal,
|
|
||||||
LessEqual,
|
|
||||||
Greater,
|
|
||||||
NotEqual,
|
|
||||||
GreaterEqual,
|
|
||||||
Always,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn identifier(value: &str) -> bool {
|
|
||||||
let mut chars = value.chars();
|
|
||||||
chars.next().is_some_and(|c| c.is_ascii_alphabetic())
|
|
||||||
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
|||||||
mod pipeline;
|
|
||||||
|
|
||||||
pub(super) use pipeline::encode_compiled;
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
use crate::renderer::{
|
|
||||||
frame_data::FrameData, gpu_scene::GpuSceneCache, material::MaterialResources,
|
|
||||||
ActiveCompiledGraph, PipelineLibrary, PreparedExecution,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub(crate) fn encode_compiled(
|
|
||||||
encoder: &mut wgpu::CommandEncoder,
|
|
||||||
surface: &wgpu::TextureView,
|
|
||||||
active: &ActiveCompiledGraph,
|
|
||||||
frame_data: &FrameData,
|
|
||||||
gpu: &GpuSceneCache,
|
|
||||||
pipelines: &PipelineLibrary,
|
|
||||||
materials: &MaterialResources,
|
|
||||||
planes: Option<&[[f32; 4]; 6]>,
|
|
||||||
) -> Result<(), &'static str> {
|
|
||||||
use crate::render_graph::{NormalizedColorLoad, NormalizedDepthLoad, StoreOp};
|
|
||||||
for compute in &active.compute {
|
|
||||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
|
||||||
label: Some(&compute.name),
|
|
||||||
timestamp_writes: None,
|
|
||||||
});
|
|
||||||
pass.set_pipeline(&compute.pipeline);
|
|
||||||
pass.dispatch_workgroups(
|
|
||||||
compute.dispatch[0],
|
|
||||||
compute.dispatch[1],
|
|
||||||
compute.dispatch[2],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let view = |resource: u32| -> Result<&wgpu::TextureView, &'static str> {
|
|
||||||
let a = active
|
|
||||||
.runtime
|
|
||||||
.allocations
|
|
||||||
.resource_allocations
|
|
||||||
.get(resource as usize)
|
|
||||||
.copied()
|
|
||||||
.flatten()
|
|
||||||
.ok_or(" resource has no allocation")?;
|
|
||||||
active
|
|
||||||
.textures
|
|
||||||
.get(a.class as usize)
|
|
||||||
.and_then(|c| c.get(a.slot as usize))
|
|
||||||
.map(|s| &s.view)
|
|
||||||
.ok_or(" allocation out of bounds")
|
|
||||||
};
|
|
||||||
for physical in &active.runtime.render_passes {
|
|
||||||
let first = *physical.executions.first().ok_or("empty physical pass")? as usize;
|
|
||||||
let last = *physical.executions.last().ok_or("empty physical pass")? as usize;
|
|
||||||
let label = if first == last {
|
|
||||||
active
|
|
||||||
.graph
|
|
||||||
.executions
|
|
||||||
.get(first)
|
|
||||||
.ok_or("execution out of bounds")?
|
|
||||||
.id
|
|
||||||
.clone()
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"{}..{}",
|
|
||||||
active
|
|
||||||
.graph
|
|
||||||
.executions
|
|
||||||
.get(first)
|
|
||||||
.ok_or("execution out of bounds")?
|
|
||||||
.id,
|
|
||||||
active
|
|
||||||
.graph
|
|
||||||
.executions
|
|
||||||
.get(last)
|
|
||||||
.ok_or("execution out of bounds")?
|
|
||||||
.id
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let (colors, depth) = match &physical.kind {
|
|
||||||
crate::render_graph::PhysicalRenderPassKind::Surface => (
|
|
||||||
vec![Some(wgpu::RenderPassColorAttachment {
|
|
||||||
view: surface,
|
|
||||||
depth_slice: None,
|
|
||||||
resolve_target: None,
|
|
||||||
ops: wgpu::Operations {
|
|
||||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
|
||||||
store: wgpu::StoreOp::Store,
|
|
||||||
},
|
|
||||||
})],
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
crate::render_graph::PhysicalRenderPassKind::Texture {
|
|
||||||
color_attachments,
|
|
||||||
depth_stencil,
|
|
||||||
} => {
|
|
||||||
let colors = color_attachments
|
|
||||||
.iter()
|
|
||||||
.map(|color| {
|
|
||||||
Ok(Some(wgpu::RenderPassColorAttachment {
|
|
||||||
view: view(color.resource)?,
|
|
||||||
depth_slice: None,
|
|
||||||
resolve_target: color.resolve_target.map(view).transpose()?,
|
|
||||||
ops: wgpu::Operations {
|
|
||||||
load: match color.load {
|
|
||||||
NormalizedColorLoad::Load => wgpu::LoadOp::Load,
|
|
||||||
NormalizedColorLoad::Clear { value } => {
|
|
||||||
wgpu::LoadOp::Clear(wgpu::Color {
|
|
||||||
r: value[0],
|
|
||||||
g: value[1],
|
|
||||||
b: value[2],
|
|
||||||
a: value[3],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
store: if color.store == StoreOp::Store {
|
|
||||||
wgpu::StoreOp::Store
|
|
||||||
} else {
|
|
||||||
wgpu::StoreOp::Discard
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>, &'static str>>()?;
|
|
||||||
let depth = depth_stencil
|
|
||||||
.as_ref()
|
|
||||||
.map(|depth| -> Result<_, &'static str> {
|
|
||||||
Ok(wgpu::RenderPassDepthStencilAttachment {
|
|
||||||
view: view(depth.resource)?,
|
|
||||||
depth_ops: Some(wgpu::Operations {
|
|
||||||
load: match depth.load {
|
|
||||||
NormalizedDepthLoad::Load => wgpu::LoadOp::Load,
|
|
||||||
NormalizedDepthLoad::Clear { value } => {
|
|
||||||
wgpu::LoadOp::Clear(value)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
store: if depth.store == StoreOp::Store {
|
|
||||||
wgpu::StoreOp::Store
|
|
||||||
} else {
|
|
||||||
wgpu::StoreOp::Discard
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
stencil_ops: None,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.transpose()?;
|
|
||||||
(colors, depth)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
|
||||||
label: Some(&label),
|
|
||||||
color_attachments: &colors,
|
|
||||||
depth_stencil_attachment: depth,
|
|
||||||
occlusion_query_set: None,
|
|
||||||
timestamp_writes: None,
|
|
||||||
});
|
|
||||||
for &member in &physical.executions {
|
|
||||||
match active
|
|
||||||
.executions
|
|
||||||
.get(member as usize)
|
|
||||||
.ok_or("prepared execution out of bounds")?
|
|
||||||
{
|
|
||||||
PreparedExecution::Fullscreen {
|
|
||||||
bind_group,
|
|
||||||
pipeline,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
pass.set_pipeline(pipeline);
|
|
||||||
pass.set_bind_group(0, bind_group, &[]);
|
|
||||||
pass.draw(0..3, 0..1);
|
|
||||||
}
|
|
||||||
PreparedExecution::Pipeline {
|
|
||||||
base,
|
|
||||||
predicate,
|
|
||||||
variant,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let traversal = active
|
|
||||||
.runtime
|
|
||||||
.instance_traversal
|
|
||||||
.as_ref()
|
|
||||||
.ok_or("compiled graph instance traversal missing")?;
|
|
||||||
for (i, group) in frame_data.bind_groups().iter().enumerate() {
|
|
||||||
pass.set_bind_group(i as u32, group, &[]);
|
|
||||||
}
|
|
||||||
if let (Some(p), Some(n), Some(u), Some(t), Some(ix), Some(inst)) = (
|
|
||||||
&gpu.positions.buffer,
|
|
||||||
&gpu.normals.buffer,
|
|
||||||
&gpu.uvs.buffer,
|
|
||||||
&gpu.tangents.buffer,
|
|
||||||
&gpu.indices.buffer,
|
|
||||||
&gpu.instances.buffer,
|
|
||||||
) {
|
|
||||||
pass.set_vertex_buffer(0, p.slice(..));
|
|
||||||
pass.set_vertex_buffer(1, n.slice(..));
|
|
||||||
pass.set_vertex_buffer(2, u.slice(..));
|
|
||||||
pass.set_vertex_buffer(4, t.slice(..));
|
|
||||||
pass.set_index_buffer(ix.slice(..), wgpu::IndexFormat::Uint32);
|
|
||||||
pass.set_vertex_buffer(3, inst.slice(..));
|
|
||||||
for (draw_index, draw) in gpu.draws.iter().enumerate() {
|
|
||||||
if !crate::renderer::instance_filter::evaluate(
|
|
||||||
traversal,
|
|
||||||
*predicate,
|
|
||||||
gpu.instance_records
|
|
||||||
.get(draw_index)
|
|
||||||
.ok_or("instance record missing")?,
|
|
||||||
gpu.local_aabb_records
|
|
||||||
.get(draw_index)
|
|
||||||
.ok_or("local aabb record missing")?,
|
|
||||||
*gpu.instance_type_records
|
|
||||||
.get(draw_index)
|
|
||||||
.ok_or("instance type record missing")?,
|
|
||||||
planes,
|
|
||||||
)? {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
pass.set_pipeline(variant);
|
|
||||||
if pipelines.requires_material(*base) {
|
|
||||||
pass.set_bind_group(2, materials.group(draw.material), &[]);
|
|
||||||
}
|
|
||||||
pass.draw_indexed(
|
|
||||||
draw.indices.clone(),
|
|
||||||
draw.base_vertex,
|
|
||||||
draw.instances.clone(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use wgpu::util::DeviceExt;
|
|
||||||
|
|
||||||
use crate::render_data::camera::Camera;
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use crate::renderer::{self, PipelineLibrary};
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub struct UniformResource {
|
|
||||||
pub buffer: wgpu::Buffer,
|
|
||||||
pub bind_group: wgpu::BindGroup,
|
|
||||||
pub bind_group_layout: wgpu::BindGroupLayout,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable, Debug, Default)]
|
|
||||||
pub struct FrameMetadata {
|
|
||||||
pub resolution: [f32; 2],
|
|
||||||
time: f32,
|
|
||||||
_padding0: f32,
|
|
||||||
pub camera_position: [f32; 4],
|
|
||||||
}
|
|
||||||
impl FrameMetadata {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub fn new(dimension: ultraviolet::Vec2) -> Self {
|
|
||||||
Self {
|
|
||||||
resolution: dimension.into(),
|
|
||||||
camera_position: [0., 0., 0., 1.],
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn set_camera_position(&mut self, p: ultraviolet::Vec3) {
|
|
||||||
self.camera_position = [p.x, p.y, p.z, 1.];
|
|
||||||
}
|
|
||||||
pub fn update_dimension(&mut self, d: ultraviolet::Vec2) {
|
|
||||||
self.resolution = d.into();
|
|
||||||
}
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub fn create_uniform_resource(self, device: &wgpu::Device) -> UniformResource {
|
|
||||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
|
||||||
label: Some("frame metadata"),
|
|
||||||
contents: bytemuck::bytes_of(&self),
|
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
|
||||||
});
|
|
||||||
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
|
||||||
label: Some("frame layout"),
|
|
||||||
entries: &[wgpu::BindGroupLayoutEntry {
|
|
||||||
binding: 0,
|
|
||||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
|
||||||
ty: wgpu::BindingType::Buffer {
|
|
||||||
ty: wgpu::BufferBindingType::Uniform,
|
|
||||||
has_dynamic_offset: false,
|
|
||||||
min_binding_size: None,
|
|
||||||
},
|
|
||||||
count: None,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
||||||
label: Some("frame group"),
|
|
||||||
layout: &bind_group_layout,
|
|
||||||
entries: &[wgpu::BindGroupEntry {
|
|
||||||
binding: 0,
|
|
||||||
resource: buffer.as_entire_binding(),
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
UniformResource {
|
|
||||||
buffer,
|
|
||||||
bind_group,
|
|
||||||
bind_group_layout,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct FrameData {
|
|
||||||
uniform_buffers: [wgpu::Buffer; 2],
|
|
||||||
bind_groups: [wgpu::BindGroup; 2],
|
|
||||||
metadata: FrameMetadata,
|
|
||||||
camera: Camera,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FrameData {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub(crate) fn new(
|
|
||||||
context: &renderer::RendererContext,
|
|
||||||
resources: &mut PipelineLibrary,
|
|
||||||
) -> Self {
|
|
||||||
let dimensions = ultraviolet::Vec2::new(
|
|
||||||
context.surface_config.width as f32,
|
|
||||||
context.surface_config.height as f32,
|
|
||||||
);
|
|
||||||
let mut metadata = FrameMetadata::new(dimensions);
|
|
||||||
let camera = Camera::new(dimensions.x / dimensions.y);
|
|
||||||
metadata.set_camera_position(camera.position());
|
|
||||||
let frame = metadata.create_uniform_resource(&context.device);
|
|
||||||
let camera_uniform = camera.create_uniform_resource(&context.device);
|
|
||||||
resources
|
|
||||||
.set_bind_group_layouts(&[frame.bind_group_layout, camera_uniform.bind_group_layout]);
|
|
||||||
Self {
|
|
||||||
uniform_buffers: [frame.buffer, camera_uniform.buffer],
|
|
||||||
bind_groups: [frame.bind_group, camera_uniform.bind_group],
|
|
||||||
metadata,
|
|
||||||
camera,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn bind_groups(&self) -> &[wgpu::BindGroup] {
|
|
||||||
&self.bind_groups
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn camera_mut(&mut self) -> &mut Camera {
|
|
||||||
&mut self.camera
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn frustum_planes(
|
|
||||||
&mut self,
|
|
||||||
) -> Result<[[f32; 4]; 6], crate::render_data::camera::FrustumError> {
|
|
||||||
self.camera.frustum_planes()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resize(&mut self, width: f64, height: f64, queue: &wgpu::Queue) {
|
|
||||||
self.metadata
|
|
||||||
.update_dimension(ultraviolet::Vec2::new(width as f32, height as f32));
|
|
||||||
self.camera
|
|
||||||
.update_aspect_ratio(width as f32 / height as f32);
|
|
||||||
self.write_uniforms(queue);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn update(&mut self, queue: &wgpu::Queue) {
|
|
||||||
self.metadata.time = js_sys::Date::now() as f32 * 0.001;
|
|
||||||
self.metadata.set_camera_position(self.camera.position());
|
|
||||||
self.write_uniforms(queue);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_uniforms(&self, queue: &wgpu::Queue) {
|
|
||||||
queue.write_buffer(
|
|
||||||
&self.uniform_buffers[0],
|
|
||||||
0,
|
|
||||||
bytemuck::bytes_of(&self.metadata),
|
|
||||||
);
|
|
||||||
queue.write_buffer(
|
|
||||||
&self.uniform_buffers[1],
|
|
||||||
0,
|
|
||||||
bytemuck::bytes_of(&self.camera.view_proj),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
use std::mem::size_of;
|
|
||||||
|
|
||||||
use bytemuck::{Pod, Zeroable};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
render_data::{MaterialKey, MeshHandle},
|
|
||||||
renderer::scene_frame::SceneFramePlan,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq)]
|
|
||||||
pub struct GpuInstance {
|
|
||||||
pub model: [[f32; 4]; 4],
|
|
||||||
pub normal_0: [f32; 4],
|
|
||||||
pub normal_1: [f32; 4],
|
|
||||||
pub normal_2: [f32; 4],
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct DrawItem {
|
|
||||||
pub material: MaterialKey,
|
|
||||||
pub mesh: MeshHandle,
|
|
||||||
pub indices: std::ops::Range<u32>,
|
|
||||||
pub base_vertex: i32,
|
|
||||||
pub instances: std::ops::Range<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq)]
|
|
||||||
pub struct GpuLocalAabb {
|
|
||||||
pub min: [f32; 4],
|
|
||||||
pub max: [f32; 4],
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct GpuScenePlan {
|
|
||||||
pub positions: Vec<[f32; 3]>,
|
|
||||||
pub normals: Vec<[f32; 3]>,
|
|
||||||
pub uvs: Vec<[f32; 2]>,
|
|
||||||
pub tangents: Vec<[f32; 4]>,
|
|
||||||
pub indices: Vec<u32>,
|
|
||||||
pub instances: Vec<GpuInstance>,
|
|
||||||
pub draws: Vec<DrawItem>,
|
|
||||||
pub local_aabbs: Vec<GpuLocalAabb>,
|
|
||||||
pub instance_types: Vec<[u32; 16]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GpuScenePlan {
|
|
||||||
pub fn build(data: &SceneFramePlan) -> Result<Self, &'static str> {
|
|
||||||
let mut p = Self::default();
|
|
||||||
let mut meshes: Vec<_> = data.meshes.iter().collect();
|
|
||||||
meshes.sort_by_key(|m| (m.material.get(), m.handle.slot(), m.handle.generation()));
|
|
||||||
for mesh in meshes {
|
|
||||||
let occurrences: Vec<_> = data.mesh_occurrence_indices[mesh.occurrence_range.clone()]
|
|
||||||
.iter()
|
|
||||||
.map(|&i| &data.occurrences[i])
|
|
||||||
.collect();
|
|
||||||
if occurrences.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let vs = mesh.geometry.vertex_start as usize;
|
|
||||||
let ve = vs
|
|
||||||
.checked_add(mesh.geometry.vertex_count as usize)
|
|
||||||
.ok_or("vertex range overflow")?;
|
|
||||||
let vertex_start = p.positions.len();
|
|
||||||
p.positions
|
|
||||||
.extend_from_slice(data.positions.get(vs..ve).ok_or("invalid vertex range")?);
|
|
||||||
p.normals
|
|
||||||
.extend_from_slice(data.normals.get(vs..ve).ok_or("invalid normal range")?);
|
|
||||||
p.uvs
|
|
||||||
.extend_from_slice(data.uvs.get(vs..ve).ok_or("invalid uv range")?);
|
|
||||||
p.tangents
|
|
||||||
.extend_from_slice(data.tangents.get(vs..ve).ok_or("invalid tangent range")?);
|
|
||||||
let first_index =
|
|
||||||
u32::try_from(p.indices.len()).map_err(|_| "index start exceeds u32")?;
|
|
||||||
let is = mesh.geometry.index_start as usize;
|
|
||||||
let ie = is
|
|
||||||
.checked_add(mesh.geometry.index_count as usize)
|
|
||||||
.ok_or("index range overflow")?;
|
|
||||||
p.indices
|
|
||||||
.extend_from_slice(data.indices.get(is..ie).ok_or("invalid index range")?);
|
|
||||||
for occurrence in occurrences {
|
|
||||||
let instance_index =
|
|
||||||
u32::try_from(p.instances.len()).map_err(|_| "instance start exceeds u32")?;
|
|
||||||
let m = &occurrence.model;
|
|
||||||
let det = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2])
|
|
||||||
- m[1][0] * (m[0][1] * m[2][2] - m[2][1] * m[0][2])
|
|
||||||
+ m[2][0] * (m[0][1] * m[1][2] - m[1][1] * m[0][2]);
|
|
||||||
p.instances.push(GpuInstance {
|
|
||||||
model: occurrence.model,
|
|
||||||
normal_0: [
|
|
||||||
occurrence.normal[0][0],
|
|
||||||
occurrence.normal[0][1],
|
|
||||||
occurrence.normal[0][2],
|
|
||||||
if det < 0. { -1. } else { 1. },
|
|
||||||
],
|
|
||||||
normal_1: [
|
|
||||||
occurrence.normal[1][0],
|
|
||||||
occurrence.normal[1][1],
|
|
||||||
occurrence.normal[1][2],
|
|
||||||
0.,
|
|
||||||
],
|
|
||||||
normal_2: [
|
|
||||||
occurrence.normal[2][0],
|
|
||||||
occurrence.normal[2][1],
|
|
||||||
occurrence.normal[2][2],
|
|
||||||
0.,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
p.local_aabbs.push(GpuLocalAabb {
|
|
||||||
min: [
|
|
||||||
mesh.local_aabb.min[0],
|
|
||||||
mesh.local_aabb.min[1],
|
|
||||||
mesh.local_aabb.min[2],
|
|
||||||
0.,
|
|
||||||
],
|
|
||||||
max: [
|
|
||||||
mesh.local_aabb.max[0],
|
|
||||||
mesh.local_aabb.max[1],
|
|
||||||
mesh.local_aabb.max[2],
|
|
||||||
0.,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
p.instance_types.push(occurrence.instance_type.words);
|
|
||||||
let base_vertex =
|
|
||||||
i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?;
|
|
||||||
p.draws.push(DrawItem {
|
|
||||||
material: mesh.material,
|
|
||||||
mesh: mesh.handle,
|
|
||||||
indices: first_index
|
|
||||||
..first_index
|
|
||||||
.checked_add(mesh.geometry.index_count)
|
|
||||||
.ok_or("draw range overflow")?,
|
|
||||||
base_vertex,
|
|
||||||
instances: instance_index..instance_index + 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct BufferSlot {
|
|
||||||
pub buffer: Option<wgpu::Buffer>,
|
|
||||||
capacity: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct GpuSceneCache {
|
|
||||||
revision: Option<u64>,
|
|
||||||
pub buffer_epoch: u64,
|
|
||||||
pub positions: BufferSlot,
|
|
||||||
pub normals: BufferSlot,
|
|
||||||
pub uvs: BufferSlot,
|
|
||||||
pub tangents: BufferSlot,
|
|
||||||
pub indices: BufferSlot,
|
|
||||||
pub instances: BufferSlot,
|
|
||||||
pub instance_records: Vec<GpuInstance>,
|
|
||||||
pub local_aabb_records: Vec<GpuLocalAabb>,
|
|
||||||
pub instance_type_records: Vec<[u32; 16]>,
|
|
||||||
pub draws: Vec<DrawItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn required_buffer_capacity(
|
|
||||||
current: u64,
|
|
||||||
required: u64,
|
|
||||||
maximum: u64,
|
|
||||||
) -> Result<u64, &'static str> {
|
|
||||||
if required > maximum {
|
|
||||||
return Err("buffer exceeds device max_buffer_size");
|
|
||||||
}
|
|
||||||
if required == 0 || current >= required {
|
|
||||||
return Ok(current);
|
|
||||||
}
|
|
||||||
Ok(current
|
|
||||||
.checked_mul(2)
|
|
||||||
.ok_or("buffer capacity overflow")?
|
|
||||||
.max(1)
|
|
||||||
.max(required)
|
|
||||||
.min(maximum))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn logical_or_zero<'a, T: Pod>(values: &'a [T], zero: &'a T) -> &'a [u8] {
|
|
||||||
if values.is_empty() {
|
|
||||||
bytemuck::bytes_of(zero)
|
|
||||||
} else {
|
|
||||||
bytemuck::cast_slice(values)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GpuSceneCache {
|
|
||||||
pub fn upload(
|
|
||||||
&mut self,
|
|
||||||
device: &wgpu::Device,
|
|
||||||
queue: &wgpu::Queue,
|
|
||||||
data: &SceneFramePlan,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if self.revision == Some(data.revision) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let p = GpuScenePlan::build(data).map_err(str::to_owned)?;
|
|
||||||
let max = device.limits().max_buffer_size;
|
|
||||||
fn bytes<T>(v: &[T]) -> Result<u64, String> {
|
|
||||||
(v.len() as u64)
|
|
||||||
.checked_mul(size_of::<T>() as u64)
|
|
||||||
.ok_or("buffer byte size overflow".into())
|
|
||||||
}
|
|
||||||
let required = [
|
|
||||||
bytes(&p.positions)?,
|
|
||||||
bytes(&p.normals)?,
|
|
||||||
bytes(&p.uvs)?,
|
|
||||||
bytes(&p.tangents)?,
|
|
||||||
bytes(&p.indices)?,
|
|
||||||
bytes(&p.instances)?.max(size_of::<GpuInstance>() as u64),
|
|
||||||
];
|
|
||||||
let slots = [
|
|
||||||
&mut self.positions,
|
|
||||||
&mut self.normals,
|
|
||||||
&mut self.uvs,
|
|
||||||
&mut self.tangents,
|
|
||||||
&mut self.indices,
|
|
||||||
&mut self.instances,
|
|
||||||
];
|
|
||||||
let usage = [
|
|
||||||
wgpu::BufferUsages::VERTEX,
|
|
||||||
wgpu::BufferUsages::VERTEX,
|
|
||||||
wgpu::BufferUsages::VERTEX,
|
|
||||||
wgpu::BufferUsages::VERTEX,
|
|
||||||
wgpu::BufferUsages::INDEX,
|
|
||||||
wgpu::BufferUsages::VERTEX,
|
|
||||||
];
|
|
||||||
let mut replaced = false;
|
|
||||||
for ((slot, &need), use_) in slots.into_iter().zip(&required).zip(usage) {
|
|
||||||
let cap = required_buffer_capacity(slot.capacity, need, max).map_err(str::to_owned)?;
|
|
||||||
if cap != slot.capacity {
|
|
||||||
slot.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
|
|
||||||
label: Some("scene buffer"),
|
|
||||||
size: cap,
|
|
||||||
usage: use_ | wgpu::BufferUsages::COPY_DST,
|
|
||||||
mapped_at_creation: false,
|
|
||||||
}));
|
|
||||||
slot.capacity = cap;
|
|
||||||
replaced = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let zero_instance = GpuInstance::zeroed();
|
|
||||||
let contents: [&[u8]; 6] = [
|
|
||||||
bytemuck::cast_slice(&p.positions),
|
|
||||||
bytemuck::cast_slice(&p.normals),
|
|
||||||
bytemuck::cast_slice(&p.uvs),
|
|
||||||
bytemuck::cast_slice(&p.tangents),
|
|
||||||
bytemuck::cast_slice(&p.indices),
|
|
||||||
logical_or_zero(&p.instances, &zero_instance),
|
|
||||||
];
|
|
||||||
let slots = [
|
|
||||||
&self.positions,
|
|
||||||
&self.normals,
|
|
||||||
&self.uvs,
|
|
||||||
&self.tangents,
|
|
||||||
&self.indices,
|
|
||||||
&self.instances,
|
|
||||||
];
|
|
||||||
for (s, c) in slots.into_iter().zip(contents) {
|
|
||||||
if !c.is_empty() {
|
|
||||||
queue.write_buffer(s.buffer.as_ref().unwrap(), 0, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if replaced {
|
|
||||||
self.buffer_epoch = self.buffer_epoch.wrapping_add(1).max(1)
|
|
||||||
}
|
|
||||||
self.instance_records = p.instances;
|
|
||||||
self.local_aabb_records = p.local_aabbs;
|
|
||||||
self.instance_type_records = p.instance_types;
|
|
||||||
self.draws = p.draws;
|
|
||||||
self.revision = Some(data.revision);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 5] {
|
|
||||||
const IA: [wgpu::VertexAttribute; 7] = wgpu::vertex_attr_array![3=>Float32x4,4=>Float32x4,5=>Float32x4,6=>Float32x4,7=>Float32x4,8=>Float32x4,9=>Float32x4];
|
|
||||||
[
|
|
||||||
wgpu::VertexBufferLayout {
|
|
||||||
array_stride: 12,
|
|
||||||
step_mode: wgpu::VertexStepMode::Vertex,
|
|
||||||
attributes: &wgpu::vertex_attr_array![0=>Float32x3],
|
|
||||||
},
|
|
||||||
wgpu::VertexBufferLayout {
|
|
||||||
array_stride: 12,
|
|
||||||
step_mode: wgpu::VertexStepMode::Vertex,
|
|
||||||
attributes: &wgpu::vertex_attr_array![1=>Float32x3],
|
|
||||||
},
|
|
||||||
wgpu::VertexBufferLayout {
|
|
||||||
array_stride: 8,
|
|
||||||
step_mode: wgpu::VertexStepMode::Vertex,
|
|
||||||
attributes: &wgpu::vertex_attr_array![2=>Float32x2],
|
|
||||||
},
|
|
||||||
wgpu::VertexBufferLayout {
|
|
||||||
array_stride: 112,
|
|
||||||
step_mode: wgpu::VertexStepMode::Instance,
|
|
||||||
attributes: &IA,
|
|
||||||
},
|
|
||||||
wgpu::VertexBufferLayout {
|
|
||||||
array_stride: 16,
|
|
||||||
step_mode: wgpu::VertexStepMode::Vertex,
|
|
||||||
attributes: &wgpu::vertex_attr_array![10=>Float32x4],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
#[test]
|
|
||||||
fn abi() {
|
|
||||||
assert_eq!(size_of::<GpuInstance>(), 112);
|
|
||||||
assert_eq!(size_of::<GpuLocalAabb>(), 32);
|
|
||||||
assert_eq!(size_of::<[u32; 16]>(), 64);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_instance_buffer_has_an_exact_zero_floor() {
|
|
||||||
assert_eq!(
|
|
||||||
logical_or_zero::<GpuInstance>(&[], &GpuInstance::zeroed()),
|
|
||||||
[0; 112]
|
|
||||||
);
|
|
||||||
assert_eq!(required_buffer_capacity(0, 112, 1024), Ok(112));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
//! CPU evaluation of graph-owned instance predicates.
|
|
||||||
//!
|
|
||||||
//! Shader source belongs to graph packages, so core evaluates its small typed
|
|
||||||
//! predicate IR directly instead of manufacturing a hidden compute shader.
|
|
||||||
|
|
||||||
use crate::render_graph::{
|
|
||||||
BooleanOp, CompareOp, ExprId, ExpressionOp, InstanceTraversalPlan, TypedLiteral,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::gpu_scene::{GpuInstance, GpuLocalAabb};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
enum Value {
|
|
||||||
Bool(bool),
|
|
||||||
F32(f32),
|
|
||||||
U32(u32),
|
|
||||||
Vector(Vec<f32>),
|
|
||||||
Matrix(Vec<Vec<f32>>),
|
|
||||||
Type([u32; 16]),
|
|
||||||
Aabb { min: [f32; 3], max: [f32; 3] },
|
|
||||||
}
|
|
||||||
|
|
||||||
fn literal(value: &TypedLiteral) -> Value {
|
|
||||||
match value {
|
|
||||||
TypedLiteral::Bool(value) => Value::Bool(*value),
|
|
||||||
TypedLiteral::F32(value) => Value::F32(*value),
|
|
||||||
TypedLiteral::U32(value) => Value::U32(*value),
|
|
||||||
TypedLiteral::Vec2(value) => Value::Vector(value.to_vec()),
|
|
||||||
TypedLiteral::Vec3(value) => Value::Vector(value.to_vec()),
|
|
||||||
TypedLiteral::Vec4(value) => Value::Vector(value.to_vec()),
|
|
||||||
TypedLiteral::Mat2(value) => {
|
|
||||||
Value::Matrix(value.iter().map(|column| column.to_vec()).collect())
|
|
||||||
}
|
|
||||||
TypedLiteral::Mat3(value) => {
|
|
||||||
Value::Matrix(value.iter().map(|column| column.to_vec()).collect())
|
|
||||||
}
|
|
||||||
TypedLiteral::Mat4(value) => {
|
|
||||||
Value::Matrix(value.iter().map(|column| column.to_vec()).collect())
|
|
||||||
}
|
|
||||||
TypedLiteral::U32x16(value) => Value::Type(*value),
|
|
||||||
TypedLiteral::LocalAabb { min, max } => Value::Aabb {
|
|
||||||
min: *min,
|
|
||||||
max: *max,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn value<'a>(values: &'a [Value], id: ExprId) -> Result<&'a Value, &'static str> {
|
|
||||||
values.get(id.0 as usize).ok_or("predicate operand missing")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn boolean(values: &[Value], id: ExprId) -> Result<bool, &'static str> {
|
|
||||||
match value(values, id)? {
|
|
||||||
Value::Bool(value) => Ok(*value),
|
|
||||||
_ => Err("predicate operand is not bool"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn f32_value(values: &[Value], id: ExprId) -> Result<f32, &'static str> {
|
|
||||||
match value(values, id)? {
|
|
||||||
Value::F32(value) => Ok(*value),
|
|
||||||
_ => Err("predicate operand is not f32"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn u32_value(values: &[Value], id: ExprId) -> Result<u32, &'static str> {
|
|
||||||
match value(values, id)? {
|
|
||||||
Value::U32(value) => Ok(*value),
|
|
||||||
_ => Err("predicate operand is not u32"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compare<T: PartialEq + PartialOrd>(operation: CompareOp, left: T, right: T) -> bool {
|
|
||||||
match operation {
|
|
||||||
CompareOp::GreaterThan => left > right,
|
|
||||||
CompareOp::LessThan => left < right,
|
|
||||||
CompareOp::Equals => left == right,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transformed(model: &[[f32; 4]; 4], point: [f32; 3]) -> [f32; 4] {
|
|
||||||
std::array::from_fn(|row| {
|
|
||||||
model[0][row] * point[0]
|
|
||||||
+ model[1][row] * point[1]
|
|
||||||
+ model[2][row] * point[2]
|
|
||||||
+ model[3][row]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn frustum_culled(
|
|
||||||
bounds: ([f32; 3], [f32; 3]),
|
|
||||||
model: &[[f32; 4]; 4],
|
|
||||||
planes: &[[f32; 4]; 6],
|
|
||||||
) -> bool {
|
|
||||||
planes.iter().any(|plane| {
|
|
||||||
(0..8).all(|corner| {
|
|
||||||
let local = std::array::from_fn(|axis| {
|
|
||||||
if corner & (1 << axis) == 0 {
|
|
||||||
bounds.0[axis]
|
|
||||||
} else {
|
|
||||||
bounds.1[axis]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let world = transformed(model, local);
|
|
||||||
plane
|
|
||||||
.iter()
|
|
||||||
.zip(world)
|
|
||||||
.map(|(left, right)| left * right)
|
|
||||||
.sum::<f32>()
|
|
||||||
< 0.0
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Evaluates one compiled raster predicate for one dense scene occurrence.
|
|
||||||
pub fn evaluate(
|
|
||||||
plan: &InstanceTraversalPlan,
|
|
||||||
predicate: ExprId,
|
|
||||||
instance: &GpuInstance,
|
|
||||||
local_aabb: &GpuLocalAabb,
|
|
||||||
instance_type: [u32; 16],
|
|
||||||
planes: Option<&[[f32; 4]; 6]>,
|
|
||||||
) -> Result<bool, &'static str> {
|
|
||||||
let mut values = Vec::with_capacity(plan.expressions.expressions.len());
|
|
||||||
for expression in &plan.expressions.expressions {
|
|
||||||
let result = match &expression.op {
|
|
||||||
ExpressionOp::Literal { literal: item } => literal(item),
|
|
||||||
ExpressionOp::InstanceType { .. } => Value::Type(instance_type),
|
|
||||||
ExpressionOp::LocalAabb { .. } => Value::Aabb {
|
|
||||||
min: local_aabb.min[..3].try_into().unwrap(),
|
|
||||||
max: local_aabb.max[..3].try_into().unwrap(),
|
|
||||||
},
|
|
||||||
ExpressionOp::Not { value: operand } => Value::Bool(!boolean(&values, *operand)?),
|
|
||||||
ExpressionOp::Boolean {
|
|
||||||
operation,
|
|
||||||
operands,
|
|
||||||
} => {
|
|
||||||
let operands = operands
|
|
||||||
.iter()
|
|
||||||
.map(|operand| boolean(&values, *operand))
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
Value::Bool(match operation {
|
|
||||||
BooleanOp::And => operands.into_iter().all(|item| item),
|
|
||||||
BooleanOp::Or => operands.into_iter().any(|item| item),
|
|
||||||
BooleanOp::Xor => operands.into_iter().fold(false, |left, right| left ^ right),
|
|
||||||
BooleanOp::Xnor => {
|
|
||||||
!operands.into_iter().fold(false, |left, right| left ^ right)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ExpressionOp::CompareF32 {
|
|
||||||
operation,
|
|
||||||
left,
|
|
||||||
right,
|
|
||||||
} => Value::Bool(compare(
|
|
||||||
*operation,
|
|
||||||
f32_value(&values, *left)?,
|
|
||||||
f32_value(&values, *right)?,
|
|
||||||
)),
|
|
||||||
ExpressionOp::CompareU32 {
|
|
||||||
operation,
|
|
||||||
left,
|
|
||||||
right,
|
|
||||||
} => Value::Bool(compare(
|
|
||||||
*operation,
|
|
||||||
u32_value(&values, *left)?,
|
|
||||||
u32_value(&values, *right)?,
|
|
||||||
)),
|
|
||||||
ExpressionOp::VectorProject { vector, index } => match value(&values, *vector)? {
|
|
||||||
Value::Vector(vector) => Value::F32(
|
|
||||||
*vector
|
|
||||||
.get(*index as usize)
|
|
||||||
.ok_or("vector predicate index out of bounds")?,
|
|
||||||
),
|
|
||||||
_ => return Err("predicate operand is not vector"),
|
|
||||||
},
|
|
||||||
ExpressionOp::VectorConstruct { components } => Value::Vector(
|
|
||||||
components
|
|
||||||
.iter()
|
|
||||||
.map(|component| f32_value(&values, *component))
|
|
||||||
.collect::<Result<_, _>>()?,
|
|
||||||
),
|
|
||||||
ExpressionOp::MatrixColumn { matrix, index } => match value(&values, *matrix)? {
|
|
||||||
Value::Matrix(matrix) => Value::Vector(
|
|
||||||
matrix
|
|
||||||
.get(*index as usize)
|
|
||||||
.ok_or("matrix predicate index out of bounds")?
|
|
||||||
.clone(),
|
|
||||||
),
|
|
||||||
_ => return Err("predicate operand is not matrix"),
|
|
||||||
},
|
|
||||||
ExpressionOp::MatrixConstruct { columns } => Value::Matrix(
|
|
||||||
columns
|
|
||||||
.iter()
|
|
||||||
.map(|column| match value(&values, *column)? {
|
|
||||||
Value::Vector(column) => Ok(column.clone()),
|
|
||||||
_ => Err("matrix column is not vector"),
|
|
||||||
})
|
|
||||||
.collect::<Result<_, _>>()?,
|
|
||||||
),
|
|
||||||
ExpressionOp::TypeWord {
|
|
||||||
value: operand,
|
|
||||||
index,
|
|
||||||
} => match value(&values, *operand)? {
|
|
||||||
Value::Type(words) => Value::U32(words[*index as usize]),
|
|
||||||
_ => return Err("predicate operand is not u32x16"),
|
|
||||||
},
|
|
||||||
ExpressionOp::TypeConstruct { words } => {
|
|
||||||
if words.len() != 16 {
|
|
||||||
return Err("type predicate requires 16 words");
|
|
||||||
}
|
|
||||||
let mut result = [0; 16];
|
|
||||||
for (index, word) in words.iter().enumerate() {
|
|
||||||
result[index] = u32_value(&values, *word)?;
|
|
||||||
}
|
|
||||||
Value::Type(result)
|
|
||||||
}
|
|
||||||
ExpressionOp::U32Bit {
|
|
||||||
value: operand,
|
|
||||||
index,
|
|
||||||
} => Value::Bool(u32_value(&values, *operand)? & (1 << index) != 0),
|
|
||||||
ExpressionOp::U32Construct { bits } => {
|
|
||||||
if bits.len() > 32 {
|
|
||||||
return Err("u32 predicate has too many bits");
|
|
||||||
}
|
|
||||||
let mut result = 0;
|
|
||||||
for (index, bit) in bits.iter().enumerate() {
|
|
||||||
result |= u32::from(boolean(&values, *bit)?) << index;
|
|
||||||
}
|
|
||||||
Value::U32(result)
|
|
||||||
}
|
|
||||||
ExpressionOp::AabbMin { aabb } => match value(&values, *aabb)? {
|
|
||||||
Value::Aabb { min, .. } => Value::Vector(min.to_vec()),
|
|
||||||
_ => return Err("predicate operand is not aabb"),
|
|
||||||
},
|
|
||||||
ExpressionOp::AabbMax { aabb } => match value(&values, *aabb)? {
|
|
||||||
Value::Aabb { max, .. } => Value::Vector(max.to_vec()),
|
|
||||||
_ => return Err("predicate operand is not aabb"),
|
|
||||||
},
|
|
||||||
ExpressionOp::FrustumCulled { local_aabb, .. } => {
|
|
||||||
let bounds = match value(&values, *local_aabb)? {
|
|
||||||
Value::Aabb { min, max } => (*min, *max),
|
|
||||||
_ => return Err("predicate operand is not aabb"),
|
|
||||||
};
|
|
||||||
let planes = planes.ok_or("camera frustum missing")?;
|
|
||||||
Value::Bool(frustum_culled(bounds, &instance.model, planes))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
values.push(result);
|
|
||||||
}
|
|
||||||
boolean(&values, predicate)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::render_graph::{
|
|
||||||
Expression, ExpressionPlan, NodeOutputRef, PipelinePredicatePlan, SemanticType,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn origin() -> NodeOutputRef {
|
|
||||||
NodeOutputRef {
|
|
||||||
node: "test".into(),
|
|
||||||
socket: "value".into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn evaluates_type_bits_without_shader_source() {
|
|
||||||
let plan = InstanceTraversalPlan {
|
|
||||||
mesh: 0,
|
|
||||||
expressions: ExpressionPlan {
|
|
||||||
expressions: vec![
|
|
||||||
Expression {
|
|
||||||
semantic_type: SemanticType::U32x16,
|
|
||||||
op: ExpressionOp::InstanceType { mesh: 0 },
|
|
||||||
origin: origin(),
|
|
||||||
mesh_provenance: Some(0),
|
|
||||||
},
|
|
||||||
Expression {
|
|
||||||
semantic_type: SemanticType::U32,
|
|
||||||
op: ExpressionOp::TypeWord {
|
|
||||||
value: ExprId(0),
|
|
||||||
index: 0,
|
|
||||||
},
|
|
||||||
origin: origin(),
|
|
||||||
mesh_provenance: Some(0),
|
|
||||||
},
|
|
||||||
Expression {
|
|
||||||
semantic_type: SemanticType::Bool,
|
|
||||||
op: ExpressionOp::U32Bit {
|
|
||||||
value: ExprId(1),
|
|
||||||
index: 3,
|
|
||||||
},
|
|
||||||
origin: origin(),
|
|
||||||
mesh_provenance: Some(0),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
pipelines: vec![PipelinePredicatePlan {
|
|
||||||
execution: 0,
|
|
||||||
predicate: ExprId(2),
|
|
||||||
ordinal: 0,
|
|
||||||
}],
|
|
||||||
requires_camera: false,
|
|
||||||
};
|
|
||||||
let mut words = [0; 16];
|
|
||||||
words[0] = 8;
|
|
||||||
assert!(evaluate(
|
|
||||||
&plan,
|
|
||||||
ExprId(2),
|
|
||||||
&GpuInstance {
|
|
||||||
model: crate::render_data::IDENTITY_MODEL_TRANSFORM,
|
|
||||||
normal_0: [0.; 4],
|
|
||||||
normal_1: [0.; 4],
|
|
||||||
normal_2: [0.; 4],
|
|
||||||
},
|
|
||||||
&GpuLocalAabb {
|
|
||||||
min: [-1., -1., -1., 0.],
|
|
||||||
max: [1., 1., 1., 0.],
|
|
||||||
},
|
|
||||||
words,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.unwrap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use image::DynamicImage;
|
|
||||||
use wgpu::util::DeviceExt;
|
|
||||||
|
|
||||||
use crate::render_data::{
|
|
||||||
upload::{AddressMode, FilterMode, Material, MaterialState, RenderDataUpload, SamplerMetadata},
|
|
||||||
MaterialKey,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum MaterialError {
|
|
||||||
#[error("unsupported image MIME type: {0}")]
|
|
||||||
Mime(String),
|
|
||||||
#[error("image decode failed: {0}")]
|
|
||||||
Decode(#[from] image::ImageError),
|
|
||||||
#[error("invalid texture, image, or sampler index")]
|
|
||||||
InvalidReference,
|
|
||||||
#[error("invalid decoded RGBA image: {0}")]
|
|
||||||
InvalidRgba(&'static str),
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MaterialBinding {
|
|
||||||
group: wgpu::BindGroup,
|
|
||||||
uniform: wgpu::Buffer,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct PreparedMaterials {
|
|
||||||
groups: HashMap<MaterialKey, MaterialBinding>,
|
|
||||||
textures: Vec<wgpu::Texture>,
|
|
||||||
views: Vec<[wgpu::TextureView; 2]>,
|
|
||||||
samplers: Vec<wgpu::Sampler>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct MaterialResources {
|
|
||||||
pub layout: wgpu::BindGroupLayout,
|
|
||||||
groups: HashMap<MaterialKey, MaterialBinding>,
|
|
||||||
fallback: MaterialBinding,
|
|
||||||
fallback_views: Vec<wgpu::TextureView>,
|
|
||||||
fallback_sampler: wgpu::Sampler,
|
|
||||||
textures: Vec<wgpu::Texture>,
|
|
||||||
views: Vec<[wgpu::TextureView; 2]>,
|
|
||||||
samplers: Vec<wgpu::Sampler>,
|
|
||||||
pub asset_epoch: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn layout_entry(binding: u32, ty: wgpu::BindingType) -> wgpu::BindGroupLayoutEntry {
|
|
||||||
wgpu::BindGroupLayoutEntry {
|
|
||||||
binding,
|
|
||||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
|
||||||
ty,
|
|
||||||
count: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn upload_rgba(
|
|
||||||
device: &wgpu::Device,
|
|
||||||
queue: &wgpu::Queue,
|
|
||||||
label: &str,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
bytes_per_row: u32,
|
|
||||||
rgba: &[u8],
|
|
||||||
) -> (wgpu::Texture, [wgpu::TextureView; 2]) {
|
|
||||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
|
||||||
label: Some(label),
|
|
||||||
size: wgpu::Extent3d {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
depth_or_array_layers: 1,
|
|
||||||
},
|
|
||||||
mip_level_count: 1,
|
|
||||||
sample_count: 1,
|
|
||||||
dimension: wgpu::TextureDimension::D2,
|
|
||||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
|
||||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
|
||||||
view_formats: &[wgpu::TextureFormat::Rgba8UnormSrgb],
|
|
||||||
});
|
|
||||||
queue.write_texture(
|
|
||||||
texture.as_image_copy(),
|
|
||||||
rgba,
|
|
||||||
wgpu::TexelCopyBufferLayout {
|
|
||||||
offset: 0,
|
|
||||||
bytes_per_row: Some(bytes_per_row),
|
|
||||||
rows_per_image: Some(height),
|
|
||||||
},
|
|
||||||
wgpu::Extent3d {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
depth_or_array_layers: 1,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let linear = texture.create_view(&wgpu::TextureViewDescriptor {
|
|
||||||
format: Some(wgpu::TextureFormat::Rgba8Unorm),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
let srgb = texture.create_view(&wgpu::TextureViewDescriptor {
|
|
||||||
format: Some(wgpu::TextureFormat::Rgba8UnormSrgb),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
(texture, [linear, srgb])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_rgba(image: DynamicImage) -> (u32, u32, Vec<u8>) {
|
|
||||||
let rgba = image.into_rgba8();
|
|
||||||
(rgba.width(), rgba.height(), rgba.into_raw())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_decoded_rgba(
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
rgba: &[u8],
|
|
||||||
max_dimension: u32,
|
|
||||||
) -> Result<u32, MaterialError> {
|
|
||||||
if width == 0 || height == 0 {
|
|
||||||
return Err(MaterialError::InvalidRgba("dimensions must be nonzero"));
|
|
||||||
}
|
|
||||||
if width > max_dimension || height > max_dimension {
|
|
||||||
return Err(MaterialError::InvalidRgba("dimensions exceed device limit"));
|
|
||||||
}
|
|
||||||
let bytes_per_row = width
|
|
||||||
.checked_mul(4)
|
|
||||||
.ok_or(MaterialError::InvalidRgba("row byte count overflows"))?;
|
|
||||||
let total = bytes_per_row
|
|
||||||
.checked_mul(height)
|
|
||||||
.ok_or(MaterialError::InvalidRgba("total byte count overflows"))?;
|
|
||||||
let total = usize::try_from(total)
|
|
||||||
.map_err(|_| MaterialError::InvalidRgba("total byte count exceeds usize"))?;
|
|
||||||
if rgba.len() != total {
|
|
||||||
return Err(MaterialError::InvalidRgba("pixel byte length is not exact"));
|
|
||||||
}
|
|
||||||
Ok(bytes_per_row)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn slot_uses_srgb(slot: usize) -> bool {
|
|
||||||
matches!(slot, 0 | 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn address(value: AddressMode) -> wgpu::AddressMode {
|
|
||||||
match value {
|
|
||||||
AddressMode::ClampToEdge => wgpu::AddressMode::ClampToEdge,
|
|
||||||
AddressMode::MirrorRepeat => wgpu::AddressMode::MirrorRepeat,
|
|
||||||
AddressMode::Repeat => wgpu::AddressMode::Repeat,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn filter(value: FilterMode) -> wgpu::FilterMode {
|
|
||||||
match value {
|
|
||||||
FilterMode::Nearest => wgpu::FilterMode::Nearest,
|
|
||||||
FilterMode::Linear => wgpu::FilterMode::Linear,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sampler_descriptor(metadata: Option<&SamplerMetadata>) -> wgpu::SamplerDescriptor<'static> {
|
|
||||||
let (mag, min, mip, s, t) = metadata.map_or(
|
|
||||||
(
|
|
||||||
wgpu::FilterMode::Linear,
|
|
||||||
wgpu::FilterMode::Linear,
|
|
||||||
wgpu::FilterMode::Linear,
|
|
||||||
wgpu::AddressMode::Repeat,
|
|
||||||
wgpu::AddressMode::Repeat,
|
|
||||||
),
|
|
||||||
|m| {
|
|
||||||
(
|
|
||||||
filter(m.mag_filter),
|
|
||||||
filter(m.min_filter),
|
|
||||||
filter(m.mipmap_filter),
|
|
||||||
address(m.address_u),
|
|
||||||
address(m.address_v),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
);
|
|
||||||
wgpu::SamplerDescriptor {
|
|
||||||
address_mode_u: s,
|
|
||||||
address_mode_v: t,
|
|
||||||
mag_filter: mag,
|
|
||||||
min_filter: min,
|
|
||||||
mipmap_filter: mip,
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MaterialResources {
|
|
||||||
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
|
|
||||||
let mut entries = vec![layout_entry(
|
|
||||||
0,
|
|
||||||
wgpu::BindingType::Buffer {
|
|
||||||
ty: wgpu::BufferBindingType::Uniform,
|
|
||||||
has_dynamic_offset: false,
|
|
||||||
min_binding_size: wgpu::BufferSize::new(112),
|
|
||||||
},
|
|
||||||
)];
|
|
||||||
for binding in 1..=5 {
|
|
||||||
entries.push(layout_entry(
|
|
||||||
binding,
|
|
||||||
wgpu::BindingType::Texture {
|
|
||||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
|
||||||
view_dimension: wgpu::TextureViewDimension::D2,
|
|
||||||
multisampled: false,
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for binding in 6..=10 {
|
|
||||||
entries.push(layout_entry(
|
|
||||||
binding,
|
|
||||||
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
|
||||||
label: Some("render-data material group 2"),
|
|
||||||
entries: &entries,
|
|
||||||
});
|
|
||||||
let colors = [[255, 255, 255, 255], [128, 128, 255, 255], [0, 0, 0, 255]];
|
|
||||||
let mut fallback_textures = Vec::new();
|
|
||||||
let mut fallback_views = Vec::new();
|
|
||||||
for color in colors {
|
|
||||||
let (t, v) = upload_rgba(device, queue, "neutral material texture", 1, 1, 4, &color);
|
|
||||||
fallback_views.push(v[0].clone());
|
|
||||||
fallback_textures.push(t);
|
|
||||||
}
|
|
||||||
let fallback_sampler = device.create_sampler(&sampler_descriptor(None));
|
|
||||||
let fallback = Self::make_group(
|
|
||||||
device,
|
|
||||||
&layout,
|
|
||||||
&Material::default(),
|
|
||||||
[
|
|
||||||
&fallback_views[0],
|
|
||||||
&fallback_views[0],
|
|
||||||
&fallback_views[1],
|
|
||||||
&fallback_views[0],
|
|
||||||
&fallback_views[2],
|
|
||||||
],
|
|
||||||
[&fallback_sampler; 5],
|
|
||||||
);
|
|
||||||
Self {
|
|
||||||
layout,
|
|
||||||
groups: HashMap::new(),
|
|
||||||
fallback,
|
|
||||||
fallback_views,
|
|
||||||
fallback_sampler,
|
|
||||||
textures: fallback_textures,
|
|
||||||
views: Vec::new(),
|
|
||||||
samplers: Vec::new(),
|
|
||||||
asset_epoch: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_group(
|
|
||||||
device: &wgpu::Device,
|
|
||||||
layout: &wgpu::BindGroupLayout,
|
|
||||||
material: &Material,
|
|
||||||
views: [&wgpu::TextureView; 5],
|
|
||||||
samplers: [&wgpu::Sampler; 5],
|
|
||||||
) -> MaterialBinding {
|
|
||||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
|
||||||
label: Some("material uniform"),
|
|
||||||
contents: bytemuck::bytes_of(&MaterialState::from(material)),
|
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
|
||||||
});
|
|
||||||
let mut entries = vec![wgpu::BindGroupEntry {
|
|
||||||
binding: 0,
|
|
||||||
resource: uniform.as_entire_binding(),
|
|
||||||
}];
|
|
||||||
for (i, view) in views.into_iter().enumerate() {
|
|
||||||
entries.push(wgpu::BindGroupEntry {
|
|
||||||
binding: i as u32 + 1,
|
|
||||||
resource: wgpu::BindingResource::TextureView(view),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (i, sampler) in samplers.into_iter().enumerate() {
|
|
||||||
entries.push(wgpu::BindGroupEntry {
|
|
||||||
binding: i as u32 + 6,
|
|
||||||
resource: wgpu::BindingResource::Sampler(sampler),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
||||||
label: Some("material bind group"),
|
|
||||||
layout,
|
|
||||||
entries: &entries,
|
|
||||||
});
|
|
||||||
MaterialBinding { group, uniform }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn prepare(
|
|
||||||
&self,
|
|
||||||
device: &wgpu::Device,
|
|
||||||
queue: &wgpu::Queue,
|
|
||||||
scene: &RenderDataUpload,
|
|
||||||
) -> Result<PreparedMaterials, MaterialError> {
|
|
||||||
let max_dimension = device.limits().max_texture_dimension_2d;
|
|
||||||
let mut textures = Vec::with_capacity(scene.images.len());
|
|
||||||
let mut views = Vec::with_capacity(scene.images.len());
|
|
||||||
for image in &scene.images {
|
|
||||||
let format = match image.mime_type.as_str() {
|
|
||||||
"image/png" => image::ImageFormat::Png,
|
|
||||||
"image/jpeg" => image::ImageFormat::Jpeg,
|
|
||||||
other => return Err(MaterialError::Mime(other.into())),
|
|
||||||
};
|
|
||||||
let decoded = image::load_from_memory_with_format(&image.encoded_data, format)?;
|
|
||||||
let (width, height, rgba) = normalize_rgba(decoded);
|
|
||||||
let bytes_per_row = validate_decoded_rgba(width, height, &rgba, max_dimension)?;
|
|
||||||
let (texture, image_views) = upload_rgba(
|
|
||||||
device,
|
|
||||||
queue,
|
|
||||||
"render-data image",
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
bytes_per_row,
|
|
||||||
&rgba,
|
|
||||||
);
|
|
||||||
drop(rgba);
|
|
||||||
textures.push(texture);
|
|
||||||
views.push(image_views);
|
|
||||||
}
|
|
||||||
let mut samplers = Vec::new();
|
|
||||||
for sampler in &scene.samplers {
|
|
||||||
samplers.push(device.create_sampler(&sampler_descriptor(Some(sampler))));
|
|
||||||
}
|
|
||||||
let default_sampler = device.create_sampler(&sampler_descriptor(None));
|
|
||||||
samplers.push(default_sampler);
|
|
||||||
let default_index = samplers.len() - 1;
|
|
||||||
let mut groups = HashMap::new();
|
|
||||||
for material in &scene.materials {
|
|
||||||
let refs = [
|
|
||||||
material.base_color_texture,
|
|
||||||
material.metallic_roughness_texture,
|
|
||||||
material.normal_texture,
|
|
||||||
material.occlusion_texture,
|
|
||||||
material.emissive_texture,
|
|
||||||
];
|
|
||||||
let mut selected_views = [
|
|
||||||
&self.fallback_views[0],
|
|
||||||
&self.fallback_views[0],
|
|
||||||
&self.fallback_views[1],
|
|
||||||
&self.fallback_views[0],
|
|
||||||
&self.fallback_views[2],
|
|
||||||
];
|
|
||||||
let mut selected_samplers = [&self.fallback_sampler; 5];
|
|
||||||
for (slot, reference) in refs.into_iter().enumerate() {
|
|
||||||
let Some(reference) = reference.filter(|r| r.tex_coord == 0) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let texture = scene
|
|
||||||
.textures
|
|
||||||
.get(reference.texture)
|
|
||||||
.ok_or(MaterialError::InvalidReference)?;
|
|
||||||
selected_views[slot] = &views
|
|
||||||
.get(texture.image)
|
|
||||||
.ok_or(MaterialError::InvalidReference)?[usize::from(slot_uses_srgb(slot))];
|
|
||||||
let sampler_index = texture.sampler.unwrap_or(default_index);
|
|
||||||
selected_samplers[slot] = samplers
|
|
||||||
.get(sampler_index)
|
|
||||||
.ok_or(MaterialError::InvalidReference)?;
|
|
||||||
}
|
|
||||||
groups.insert(
|
|
||||||
material.key,
|
|
||||||
Self::make_group(
|
|
||||||
device,
|
|
||||||
&self.layout,
|
|
||||||
material,
|
|
||||||
selected_views,
|
|
||||||
selected_samplers,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(PreparedMaterials {
|
|
||||||
groups,
|
|
||||||
textures,
|
|
||||||
views,
|
|
||||||
samplers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn install(&mut self, prepared: PreparedMaterials, asset_epoch: u64) {
|
|
||||||
self.groups = prepared.groups;
|
|
||||||
self.textures = prepared.textures;
|
|
||||||
self.views = prepared.views;
|
|
||||||
self.samplers = prepared.samplers;
|
|
||||||
self.asset_epoch = asset_epoch;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn group(&self, key: MaterialKey) -> &wgpu::BindGroup {
|
|
||||||
&self.groups.get(&key).unwrap_or(&self.fallback).group
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn synchronize(
|
|
||||||
&self,
|
|
||||||
queue: &wgpu::Queue,
|
|
||||||
rows: &[(MaterialKey, [u32; MaterialState::LANES as usize])],
|
|
||||||
) {
|
|
||||||
for (key, words) in rows {
|
|
||||||
let binding = self
|
|
||||||
.groups
|
|
||||||
.get(key)
|
|
||||||
.or_else(|| (*key == MaterialKey::DEFAULT).then_some(&self.fallback));
|
|
||||||
if let Some(binding) = binding {
|
|
||||||
let state = MaterialState::from_words(*words);
|
|
||||||
queue.write_buffer(&binding.uniform, 0, bytemuck::bytes_of(&state));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn schlick(f0: f32, cosine: f32) -> f32 {
|
|
||||||
f0 + (1.0 - f0) * (1.0 - cosine.clamp(0.0, 1.0)).powi(5)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ggx_d(n_h: f32, alpha: f32) -> f32 {
|
|
||||||
let n_h = n_h.clamp(0.0, 1.0);
|
|
||||||
let alpha2 = alpha * alpha;
|
|
||||||
let n_h2 = n_h * n_h;
|
|
||||||
let q = (1.0 - n_h2) + alpha2 * n_h2;
|
|
||||||
alpha2 / (std::f32::consts::PI * q * q)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn smith_v(n_v: f32, n_l: f32, alpha: f32) -> f32 {
|
|
||||||
let alpha2 = alpha * alpha;
|
|
||||||
let gv = n_l * (n_v * n_v * (1.0 - alpha2) + alpha2).max(0.0).sqrt();
|
|
||||||
let gl = n_v * (n_l * n_l * (1.0 - alpha2) + alpha2).max(0.0).sqrt();
|
|
||||||
0.5 / (gv + gl).max(1e-6)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pbr_reference_equations_have_known_limits_and_stay_finite() {
|
|
||||||
assert!((schlick(0.04, 1.0) - 0.04).abs() < 1e-6);
|
|
||||||
assert!((schlick(0.04, 0.0) - 1.0).abs() < 1e-6);
|
|
||||||
assert!((ggx_d(0.0, 1.0) - std::f32::consts::FRAC_1_PI).abs() < 1e-6);
|
|
||||||
let expected_quarter = 16.0 / std::f32::consts::PI;
|
|
||||||
for actual in [ggx_d(1.0, 0.25), ggx_d(1.0 + f32::EPSILON, 0.25)] {
|
|
||||||
assert!((actual - expected_quarter).abs() / expected_quarter < 1e-6);
|
|
||||||
}
|
|
||||||
for value in [ggx_d(1.0, 0.045 * 0.045), smith_v(0.0, 0.0, 0.002025)] {
|
|
||||||
assert!(value.is_finite() && value >= 0.0);
|
|
||||||
}
|
|
||||||
let roughness_floor_peak = ggx_d(1.0, 0.045 * 0.045);
|
|
||||||
let expected = 1.0 / (std::f32::consts::PI * 0.045_f32.powi(4));
|
|
||||||
assert!((roughness_floor_peak - expected).abs() / expected < 1e-6);
|
|
||||||
assert!((roughness_floor_peak - 77_624.0).abs() / 77_624.0 < 1e-4);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn material_uniform_is_112_bytes() {
|
|
||||||
assert_eq!(std::mem::size_of::<MaterialState>(), 112);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn texcoord_one_disables_slot() {
|
|
||||||
let mut m = Material::default();
|
|
||||||
m.base_color_texture = Some(crate::render_data::upload::TextureReference {
|
|
||||||
texture: 0,
|
|
||||||
tex_coord: 1,
|
|
||||||
});
|
|
||||||
assert_eq!(MaterialState::from(&m).flags[0] & 1, 0);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn material_packing_includes_ior_f0_flags_and_uv_sets() {
|
|
||||||
let mut m = Material::default();
|
|
||||||
m.ior = 2.0;
|
|
||||||
m.double_sided = true;
|
|
||||||
m.normal_texture = Some(crate::render_data::upload::TextureReference {
|
|
||||||
texture: 4,
|
|
||||||
tex_coord: 0,
|
|
||||||
});
|
|
||||||
let gpu = MaterialState::from(&m);
|
|
||||||
assert_eq!(gpu.alpha_optics[2], 2.0);
|
|
||||||
assert!((gpu.alpha_optics[3] - 1.0 / 9.0).abs() < 1e-6);
|
|
||||||
assert_eq!(gpu.flags[0] & (1 << 2), 1 << 2);
|
|
||||||
assert_eq!(gpu.flags[1], 1);
|
|
||||||
assert_eq!(gpu.uv_sets[2], 0);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn explicit_ior_sentinel_packs_unit_f0() {
|
|
||||||
let mut material = Material::default();
|
|
||||||
material.ior = 0.0;
|
|
||||||
let gpu = MaterialState::from(&material);
|
|
||||||
assert_eq!(gpu.alpha_optics[2], 0.0);
|
|
||||||
assert_eq!(gpu.alpha_optics[3], 1.0);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn sampler_translation_is_exact() {
|
|
||||||
let m = SamplerMetadata {
|
|
||||||
mag_filter: FilterMode::Nearest,
|
|
||||||
min_filter: FilterMode::Linear,
|
|
||||||
mipmap_filter: FilterMode::Nearest,
|
|
||||||
address_u: AddressMode::ClampToEdge,
|
|
||||||
address_v: AddressMode::MirrorRepeat,
|
|
||||||
};
|
|
||||||
let d = sampler_descriptor(Some(&m));
|
|
||||||
assert_eq!(d.mag_filter, wgpu::FilterMode::Nearest);
|
|
||||||
assert_eq!(d.min_filter, wgpu::FilterMode::Linear);
|
|
||||||
assert_eq!(d.mipmap_filter, wgpu::FilterMode::Nearest);
|
|
||||||
assert_eq!(d.address_mode_u, wgpu::AddressMode::ClampToEdge);
|
|
||||||
assert_eq!(d.address_mode_v, wgpu::AddressMode::MirrorRepeat);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn rgb_and_luma_normalize_to_rgba() {
|
|
||||||
let (_, _, rgb) = normalize_rgba(DynamicImage::ImageRgb8(
|
|
||||||
image::RgbImage::from_raw(1, 1, vec![1, 2, 3]).unwrap(),
|
|
||||||
));
|
|
||||||
assert_eq!(rgb, [1, 2, 3, 255]);
|
|
||||||
let (_, _, luma) = normalize_rgba(DynamicImage::ImageLuma8(
|
|
||||||
image::GrayImage::from_raw(1, 1, vec![7]).unwrap(),
|
|
||||||
));
|
|
||||||
assert_eq!(luma, [7, 7, 7, 255]);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn odd_width_layout_is_tight() {
|
|
||||||
let width = 3;
|
|
||||||
assert_eq!(width * 4, 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decoded_rgba_validation_rejects_dimensions_overflow_and_wrong_length() {
|
|
||||||
assert!(validate_decoded_rgba(0, 1, &[], 4096).is_err());
|
|
||||||
assert!(validate_decoded_rgba(4097, 1, &[], 4096).is_err());
|
|
||||||
assert!(validate_decoded_rgba(u32::MAX, 2, &[], u32::MAX).is_err());
|
|
||||||
assert!(validate_decoded_rgba(2, 2, &[0; 15], 4096).is_err());
|
|
||||||
assert_eq!(validate_decoded_rgba(3, 2, &[0; 24], 4096).unwrap(), 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_color_roles_use_srgb_views() {
|
|
||||||
assert_eq!(
|
|
||||||
(0..5).map(slot_uses_srgb).collect::<Vec<_>>(),
|
|
||||||
[true, false, false, false, true]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user