Skip to content

feat(picking): ray-pick Models via _worldBounds + linear scan#112

Merged
krisnye merged 111 commits into
mainfrom
krisnye/data-gpu
Jun 10, 2026
Merged

feat(picking): ray-pick Models via _worldBounds + linear scan#112
krisnye merged 111 commits into
mainfrom
krisnye/data-gpu

Conversation

@krisnye

@krisnye krisnye commented May 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds ray-against-Model picking as a foundation for selection, hover, and (later) physics. Linear-scan today; a comment block in `picking-plugin.ts` sketches a `Broadphase` resource interface so a uniform-grid / BVH / GPU-compute impl can slot in without changing the public API.

Changes

  • `node-plugin.ts` — adds `pickable: True.schema` (opt-out flag) and `_worldBounds: Aabb.schema` (per-frame derived AABB) components.
  • `model-plugin.ts` — `Model` archetype includes `pickable`; `insertModel` defaults to `true` (mirrors `visible`).
  • `world-bounds-plugin.ts` (new) — two systems mirroring the transform pattern:
    • `worldBoundsCreate` (after `transformCreateWorldMatrix`) migrates Models into the wider archetype with `_worldBounds`.
    • `worldBoundsSystem` (after `transformSystem`) transforms each Geometry's `_bounds` by the Model's `_worldMatrix` (8 corners → reduced AABB) and writes the column.
  • `picking/` (new):
    • `pick-hit.ts` — `PickHit { entity, distance }`.
    • `picking-plugin.ts` — exposes `pickRay(ray)` and `pickFromNdc({ ndcX, ndcY })` as actions (transactions are restricted to `Entity | void` returns). Linear scan over `["geometry", "visible", "pickable", "_worldBounds"]` using `Aabb.lineIntersection`.
  • Solar-system sample — `@click` handler computes NDC and invokes a sample-local `pickAndFit` action that reads the hit Model's `geometry` and calls `setOrbit({ fitGeometry })`, leveraging the existing auto-fit system to reframe on the clicked planet.

Test plan

  • `pnpm --filter @adobe/data-gpu run build` clean
  • `pnpm --filter @adobe/data-gpu test` — 5 tests pass
  • `pnpm --filter data-gpu-samples run build` clean
  • Solar-system click-to-focus verified via chrome-devtools MCP: clicking near the sun reframes the orbit on it
  • Skinned-fox sample unaffected (renders normally, no console errors)
  • Reviewer: click a smaller planet — orbit should reframe on it (not the sun)
  • Reviewer: open devtools and verify `service.actions.pickFromNdc({ndcX:0,ndcY:0})` returns a non-null `PickHit` when something is under the screen center

🤖 Generated with Claude Code

krisnye and others added 30 commits May 14, 2026 21:44
Adds @adobe/data-graphics and data-graphics-samples packages with a
full PBR IBL renderer (split-sum, BRDF LUT, prefiltered env, irradiance).

Key fix: BRDF LUT was all-zeros because importance_sample_ggx degenerates
when N=(0,0,1) — cross product produces (0,0,0) whose normalization is NaN,
so every sample was silently rejected. Fixed by computing H directly in
tangent space in brdf-lut.ts, bypassing the TBN rotation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace imperative loadGltfModel() calls with a declarative ECS pattern.
Users insert a Geometry entity with pbrModelUrl; the pbrModelLoader system
fetches the GLTF, uploads to GPU, and writes pbrModelBounds when done.
A Model entity references a Geometry via pbrGeometryRef and carries node
transforms + visibility — renderers only draw primitives whose Geometry
has at least one visible Model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Split the combined GPU mesh+material entity into two: PbrMaterial holds
the bind group, PbrPrimitive holds vertex/index buffers and a pbrMaterialRef
pointing to its material. Renderers build a materialMap per frame and skip
redundant setBindGroup calls by comparing bind group object identity.

This is the foundation for material sorting (group draws by bind group to
minimize GPU state changes) and future instanced rendering where materials
and geometry vary independently across draw calls.

Fix: use GPUBindGroup object reference as the redundancy sentinel instead
of an integer ID; ephemeral entity IDs are negative starting at -1, which
collided with the -1 integer sentinel and silently skipped setBindGroup
on the first primitive every frame.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both IBL and direct renderers now issue drawIndexed with instanceCount
derived from the ECS Model entities. Each visible Model contributes a
mat4x4 (built from position/rotation/scale) to a per-Geometry GPU
storage buffer uploaded each frame. The vertex shader reads the matrix
via @Builtin(instance_index) and applies a cofactor normal transform.

Adds pbr-ibl-instanced sample: 4×4 grid of DamagedHelmet sharing one
Geometry entity, rendered with a single drawIndexed per primitive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extracts createCubemap, cubeFaceView, cubemapSampleView from the
private ibl/ folder into a public GPU namespace (src/gpu/) following
the domain namespace pattern for types we don't own. render-helpers.ts
re-exports from there so existing ibl/ imports are unchanged.

Documents the domain namespace pattern in namespace.md: purpose-driven
name (GPU not GPUDevice), no type re-export, static-access tree-shaking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a parent/world-transform system and a solar system demo that exercises
nested model hierarchies with orbital animation over PBR IBL-lit spheres.

- node plugin gains `parent: number` (default 0 = root) on all entities,
  keeping roots and children in the same archetype so a single forward
  pass computes world matrices in insertion order
- new transform plugin computes `worldMatrices: Map<number, Mat4x4>` each
  preRender; renderers (pbr-ibl, pbr-direct) read it instead of computing
  TRS inline
- pbrShapes plugin: `insertSphere` + procedural UV-sphere geometry with
  `createColorMaterial` supporting emissive/metallic/roughness overrides
- solar-system sample: Sun (emissive), Mercury, Venus, Earth, Mars +
  Moon whose transform parents to Earth, demonstrating the hierarchy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Vertices no longer have the glTF node hierarchy baked in at load time.
Each PbrPrimitive now carries pbrNodeLocalMatrix (the node's local-to-
model-root matrix); renderers pre-multiply modelWorldMatrix × nodeLocalMatrix
per primitive to build effective GPU instance matrices.

- pack-vertex-buffer: remove worldMatrix param; vertices stay in node-local
  space, making them compatible with future GPU skinning (joint matrices
  replace the rigid node transform)
- load-gltf-model: store worldMatrix as pbrNodeLocalMatrix per primitive;
  transform node-local AABB corners by world matrix for correct model bounds
- pbr-core: add pbrNodeLocalMatrix component to PbrPrimitive archetype
- pbr-ibl + pbr-direct: collect modelMatsByGeo as Mat4x4[], then per-
  primitive compute effective = modelMat × nodeMatrix, write to per-
  primitive instance buffer (keyed by primitive entity ID)
- pbr-shapes: pass Mat4x4.identity as pbrNodeLocalMatrix (sphere vertices
  are already in object-local space)
- gltf-types: add GltfSkin type, skin? on GltfNode, JOINTS_0/WEIGHTS_0 on
  GltfPrimitive, skins? on GltfAsset (type-level skinning prep)

Single-mesh and instanced rendering unchanged in output; multi-mesh
glTF models now correctly separate per-node transforms from vertex data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PBR is just a collection of plugins and types. src/pbr/ was an
unnecessary intermediate layer that mirrored the top-level structure.

- pbr/types/{pbr-material,standard-vertex} → src/types/
- pbr/plugins/pbr-core, pbr-shapes → src/plugins/ (flat, simple files)
- pbr/plugins/pbr-model-loader + pbr/gltf/ → src/plugins/pbr-model-loader/
  (cohesion rule: plugin promoted to folder, owns its gltf/ sub-files)
- pbr/plugins/pbr-ibl + pbr/ibl/ + IBL shaders → src/plugins/pbr-ibl/
- pbr/plugins/pbr-direct + direct shader → src/plugins/pbr-direct/
- pbr/bind-group-layouts.ts → src/plugins/ (private shared file)
- src/pbr/index.ts removed; its exports folded into src/index.ts
- All import paths updated; zero type errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ansforms

Demonstrates pbrNodeLocalMatrix working correctly across 6 glTF nodes
(truck body + 2 wheel groups at translated positions, all under a Y-up
convention rotation root). The wheels render at their correct offsets
relative to the truck body — proving the per-primitive node matrix path.

Model: CesiumMilkTruck © 2017 Cesium, CC-BY 4.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…antique-camera

All model and HDRI references now point to Khronos Sample Assets and
Poly Haven CDN respectively — no binaries committed to the repo.
CesiumMilkTruck sample replaced by AntiqueCamera (richer PBR materials).
public/models/ and public/env/ added to .gitignore.

Binary assets removed from branch history via git filter-branch (they
existed only on this branch, never in main).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…d config

Reflects the broader ambition of the package — not just rendering but
general WebGPU compute as well.

  packages/data-graphics/     → packages/data-gpu/
  packages/data-graphics-samples/ → packages/data-gpu-samples/
  @adobe/data-graphics        → @adobe/data-gpu
  data-graphics-samples       → data-gpu-samples
  dev-graphics script         → dev-gpu
  CI deploy path              → gpu-samples

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Animation plugin: AnimationClip + AnimationPlayer archetypes, sample
  system, schema-driven interpolation dispatch (no type-specific
  switches in the animation system). Quat declares slerp in its schema.
- Per-component interpolators via optional Schema.interpolators field.
  componentwiseLerp is the default for arrays of numbers/scalars.
- Solar-system: orbit animation now driven by AnimationClip keyframes
  + AnimationPlayer instead of bespoke orbitSystem + Map closure.
- All sample plugins gained an initializeScene transaction that bundles
  setIblEnvironmentUrl + setLight + insertGeometry + insertModel and
  registers an autoFitOrbit system so the camera fits to bounds
  automatically once the asset loads.
- All sample elements switched to DatabaseElement<typeof plugin> +
  hooks (useElement, useEffect, useOrbitDragCamera). No more @State
  fields, pointer handlers, firstUpdated overrides, or service-helper
  constructors. Lit hooks subsystem is now the primary lifecycle path.
- Drag-to-orbit gesture extracted into a shared useOrbitDragCamera hook.
- type-casts.md rule expanded to clearly enumerate the two valid uses
  of `as` (proving validity TS cannot see; widening literals/defaults).
  All new code reviewed for compliance; identity casts removed.
- Misc: Quat type defined directly as readonly [n,n,n,n] tuple rather
  than via Schema.ToType<typeof schema> so the schema can reference
  slerp without a type-level cycle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Composes the four-layer pipeline:

  1. animation: animationSampleSystem advances clip time, samples each
     track at the current time, writes the sampled value to the joint
     entity's component (position/rotation/scale). Schema-declared
     interpolators dispatch slerp automatically for Quat tracks.
  2. transform: existing transformSystem walks the joint hierarchy and
     produces a worldMatrices map for all node-archetype entities,
     including the new joint entities.
  3. pbrSkinningMatrixSystem (new): for each Skeleton, computes
     inverse(modelWorld) × jointWorld × IBM per joint and uploads
     N×Mat4x4 to the skeleton's storage buffer; also writes the model
     world matrix to the skeleton's per-Model instance buffer. Runs in
     preRender so the IBL renderer sees fresh joint matrices.
  4. pbrIbl renderer: builds a second pipeline using a skinned WGSL
     variant. Skinned VS attributes are a separate vertex buffer
     (uint32×4 joints + float32×4 weights) so static meshes pay no
     per-vertex cost. Bind group 3 has two storage entries — instance
     matrix + joint matrices — staying within WebGPU's default
     maxBindGroups = 4.

glTF parsing:
- parse-skin.ts builds the joint template (per-joint local TRS +
  parent-joint index) and reads inverseBindMatrices into a flat
  Float32Array.
- parse-animations.ts converts channels/samplers to AnimationTrack[],
  resolving target glTF node indices to joint indices for clip
  portability across instances. linear / step / cubicSpline modes
  are passed through (cubicSpline TBD on the consumer side).
- pack-vertex-buffer tolerates missing NORMAL / TEXCOORD_0 (Fox lacks
  both) and packs an optional secondary skinning attribute buffer when
  JOINTS_0 + WEIGHTS_0 are present.
- load-gltf-model synthesises sequential indices for non-indexed
  primitives (Fox is non-indexed).
- pbrInsertLoadedPrimitives also inserts AnimationClip entities for
  each parsed animation and writes their ids onto the Geometry as
  animationClipRefs.

pbr-skinning plugin (new): owns the Skeleton archetype + a lazy init
system that, for each Model whose Geometry has skinJointTemplate,
spawns one joint entity per template entry (reusing the node plugin's
TRS components), allocates two GPU buffers + a bind group for the
skeleton, sets animationSkeletonRef on the Model, and — if the
Geometry came with clips — also inserts an AnimationPlayer whose
animationTargets are the new joint entity ids. The same generic
animation plugin drives the joints; no skinning-specific animation
code.

Sample: skinned-fox loads Khronos Fox.glb and plays the walk-cycle
animation. Camera plane range is widened (orbitRadius × 4) because
Fox is authored in cm — the default 0.1/100 planes clip it.

Misc:
- node plugin gains a Node archetype so joints can be inserted as
  plain TRS entities.
- pbrCore gains pbrSkinVertexBuffer + skeletonModelRef +
  skeletonJointMatrixBindGroup + animationSkeletonRef so renderers
  can query for them without depending on pbr-skinning.
- All sample Model.insert call sites now pass animationSkeletonRef: 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ugin

Every sample had its own orbit resources, transactions, and camera
system. The pattern was nearly identical: orbitCenter / Radius / Height
/ Angle / AutoSpin resources, a setOrbit / addOrbitAngle / resumeAutoSpin
transaction trio, an orbitCamera system that wrote the scene-uniforms
`camera` resource each frame, and (for the asset-loading samples) an
autoFitOrbit system that read pbrModelBounds and called setOrbit.

Extracts all of that into `orbitCamera` in @adobe/data-gpu. Each sample
plugin now just extends it and writes the auto-fit factors (or, for
solar-system, the fixed orbit values) inside its `initializeScene`
transaction. The near/far planes derive from the orbit radius so big
models like Fox no longer get clipped by the default 0.1/100 planes.

Adds a `useOrbitCameraControl(service)` hook on the samples side that
wires drag-to-rotate + drag-end-resumes-auto-spin to any service that
extends orbit-camera. The previous useOrbitDragCamera (lower-level,
callback-shaped) stays for one-off uses; useOrbitCameraControl is the
default for samples.

Net: -172 lines across the samples, 14 files changed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Records the rule and the two public CDNs we use (Khronos Sample Assets
for glTF, Poly Haven for HDRIs), so future agent runs don't re-add
the binaries we already had to scrub from git history once.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Records the work we discussed but deferred: tests for the animation
and skinning code, shadow mapping, multi-clip blending, cubicSpline
interpolation, asset URL dedup, skinned-mesh instancing, camera
improvements, and smaller items. So none of these get lost while
we focus on GPU compute next.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
50k–1M boid flock simulation written entirely against WebGPU compute.
Architecture:

  Per frame, six command-buffer ops, zero CPU↔GPU sync:
    1. clear_cells       — zero the 32³ uniform-grid cell-count atomics
    2. populate_grid     — atomicAdd cell counters per boid
    3. prefix_sum        — single-thread exclusive scan to cellOffsets
                           (also seeds cellWriteCursors)
    4. bin_boids         — atomic-scatter boid indices into sortedIndices
    5. update_boids      — read sorted neighbours from current cell and
                           26 adjacent cells, apply cohesion / alignment
                           / separation, write new state to the ping-pong
                           target. Boid 0 also writes the indirect-draw
                           args (5 × u32).
    6. drawIndexedIndirect — vertex shader indexes into the just-written
                           state buffer; orientation derives from velocity.

No per-boid entities: state lives entirely in GPU storage buffers
referenced from the database as resources. Two state buffers form a
ping-pong; bind-group swap is the only per-frame state change. Render
pass reads the buffer the compute pass just wrote, so the rendered
flock is always one frame fresh, never stale.

Performance on this machine (M-series Apple Silicon, browser):
  50k boids   120 fps (vsync-capped)
  100k boids  120 fps (vsync-capped)
  250k boids  49  fps   20 ms/frame
  500k boids  17  fps   60 ms/frame
  1M boids    4   fps  260 ms/frame

Reference points: the WebGPU canonical sample baselines at 1.5k boids;
Sebastian Lague's Unity flocking demo runs 200k at 60 fps natively.
Bottleneck above 250k is the update pass's neighbour-walk cost — uniform
grid is fine up through 100k+, spatial-hash + workgroup-shared-memory
caching would push it further.

Future generalisation: the resources-as-buffers pattern, the indirect-
draw flow, and the compute-pass orchestration are all candidates to
lift into a `gpuCompute` plugin in @adobe/data-gpu — but we'll
generalise from the sample, not before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
50k boids in a 20-unit world filled the volume so uniformly that no
emergent group behaviour was visible — every boid had ~45 neighbours
inside its view sphere, so cohesion/alignment averaged out to nothing.

Two changes:

- Lowered DEFAULT_BOIDS to 4 000, made the arrowhead 3× larger, brought
  the camera closer, and bumped alignment / max speed. Now distinct
  flocks form, drift, and split.
- Replaced toroidal wrap with an aquarium: a linear inward force ramps
  up over the last 2.5 units of each axis (`wallGain = 12`), and a
  hard clamp at ±worldExtent is the last-resort backstop. Flocks make
  smooth U-turns at the walls instead of teleporting across.

Grid was tightened: 1000 cells (10³, cellSize = 2.0) which matches the
boid view radius so the 3×3×3 neighbour scan covers every possible
neighbour. The previous 32³ grid had cellSize = 0.625 — smaller than
the view radius — so neighbours outside the 3×3×3 window were silently
missed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three changes to push the sim from a jiggling fog into visible
schooling:

  1. Cut DEFAULT_BOIDS to 2 500. With viewR ≈ 1.6 in a 20-unit cube
     this gives ~8 neighbours per boid — the Reynolds sweet spot.
     Above that, force averaging smooths every group out to nothing.

  2. Seeded positions via rejection sampling against a 3D sum-of-sines
     density function ('densityNoise'). Boids start clumped into a few
     blobs at ~6-unit feature scale instead of a uniform cloud. The
     dynamics inherit that structure on frame 1.

  3. Seeded velocities from a curl-noise-like flow field. Boids in the
     same blob start moving in the same direction, so alignment locks
     them into a coherent flock immediately rather than spending the
     first few seconds finding each other.

Also tightened the view radius (separationDist 0.8 → viewR 1.6) and
softened cohesion (gain 0.6) so flocks can break apart and reform
instead of clumping into one ball.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Aquarium walls were trapping flocks against faces — the wall force +
the flock's alignment kept everyone pointed at the boundary, where
they jiggled. Wrap-around lets schools migrate freely across the
volume; they reappear on the opposite face and continue.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mouse moves are unprojected onto the plane through the orbit center
perpendicular to the view, then fed into the compute shader as a
world-space scare point. Boids within scareRadius (3 units) feel an
outward force that ramps linearly from full at distance 0 to zero at
the radius — strong enough (gain 18) that the swarm carves a clear
void around the cursor and flocks visibly stream out of the way.

Params struct grows from 48 to 80 bytes:
  + scareData    : vec4f   xyz = position, w = active flag
  + scareTuning  : vec4f   x = radius, y = gain, zw unused

Element listens for pointermove/leave on the canvas and calls
setScareFromNdc / disableScare. The unprojection lives in the
transaction since it needs the camera resource.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Auto-loads whenever a plugin file is touched (path patterns match
plugins/** and *-service.ts). Captures the framing we worked out:

  - Two orthogonal questions: what's authored (human surface) vs.
    what's derived (system implementation, AI's land).
  - Two tiers of model plugins: a small fixed core (node, camera,
    light, model+geometry) and an open set of authoring abstractions
    (orbit, shape, animation, ...) that systems expand into core
    state.
  - A concise visualisation format for sketching plugins: indent
    components/resources/archetypes; archetypes compose via
    [field, ...OtherArchetype]; entity references typed as EntityId
    drop the 'Ref' suffix; drop implementation prefixes (pbr/ibl)
    from the conceptual view.

Includes a worked example showing what `corePlugin` looks like under
this format — 20 fields total covers the whole renderable-scene
surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous draft baked graphics vocabulary into the framing (renderer,
camera, light, IBL) which made it look graphics-specific even though
the model/system + authored/derived split applies to any ECS plugin.

Changes:
  - Worked example is now a chat domain (user, channel, message) which
    exercises every part of the format (components, resources, archetypes,
    spread composition, optional fields) without any rendering vocabulary.
  - "Core model" framed as "primary record types of the domain" rather
    than "what every renderer needs."
  - Path patterns broadened to **/*plugin* and **/*plugin*/** so the
    rule auto-loads on any file or folder containing the word plugin,
    not just files matching the data-gpu samples conventions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…convention

Records the conventions we settled on:

  - System plugin format (query / read / write outline) alongside the
    existing model plugin format.
  - Query DSL: Archetype+component-component, comma-separated for
    multiple independent queries.
  - Underscore prefix marks ephemeral / derived / not-part-of-data-model
    in both docs and code. Applies to component, resource, and archetype
    names.
  - Inline ': Type' annotations appear only on _-prefixed names; the
    type of authored items is read from the data plugin where they
    are declared.

Rule trimmed for a Sonnet-or-better reader — no extended worked
examples, just the format spec and conventions. The conventions
generalise the model format too (shared section), so both formats
stay consistent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…refix from plugins, suffix plugin files

Nest every feature folder (node, camera, light, model, animation,
orbit, scene-uniforms, pbr, vertices) plus the scene aggregator under
graphics/, leaving the package root open for a future compute/ sibling.

Strip the _ prefix from plugin and system names — folder grouping now
carries the "implementation vs. authored" signal. The _ prefix stays on
components, archetypes, and ephemeral resources where it still marks
derived state inside the data model.

Every plugin file ends in -plugin.ts (node-plugin.ts, pbr-core-plugin.ts,
...) so cursor rules and grep can match plugins by filename without
inspecting contents. Export names stay short (node, pbrCore, ...).

Type relocations:
- types/* folders move next to the feature that owns them
- pbr-material renamed to visible-material; archetype _PbrMaterial →
  _VisibleMaterial
- animation-types.ts split into proper namespaces:
  interpolation-mode/ and animation-track/ (with AnimationTrack.sample)
- gltf-types.ts renamed to gltf-schema.ts (external-schema lump)
- ColorMaterialOptions moved into visible-material/ where it belongs

Animation performance: split the plugin into a data half plus the
systems half, declare advanceAnimations' t parameter as the store type
via Database.Plugin.ToStore, and run the loop twice each frame — once
through db.transactions (observable players, observers fire) and once
directly against db.store (non-observable players, no notification
overhead). Two archetypes (Animation, AnimationObservable) so the
choice happens at insert time without a row migration.

Transform: split _worldMatrix creation from world-matrix computation so
the second system can write into an existing column without migration.

Rules updated:
- namespace.md: multi-declaration files section, never *-types.ts,
  selective -plugin file suffix
- plugin-modelling.md: _ prefix scoped to components/archetypes/
  resources; schemas-over-defaults for components; archetypes rule
- archetypes.md (new): declarative include/exclude queries, tail→head
  migration

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Scare is now a ray from the camera eye through the cursor toward the
  far plane; boids within a perpendicular radius of that line are pushed
  outward regardless of depth. Camera rotation is honored by virtue of
  the ray being computed from cam.forward/right/up each frame.
- Default boid count up to 4000 for a denser flock.
- Boid color: hue from facing direction (forward unit vector remapped
  to [0,1] per channel), intensity scaled 0.35→1.0 by speed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… + API cleanup

## Structure

- `scene/` — authored content: `node/`, `model/`, `light/`, `scene-uniforms/`
  - `node/`: `node-data-plugin` + `transform-plugin` + `node-plugin` (combiner)
  - `model/`: `model-plugin` (data), `model-loader-plugin`, `world-bounds-plugin`, `gltf/`
  - `light/`: `light-plugin` (data)
  - `scene-uniforms/`: type + plugin (camera + light → GPU uniform buffer)
  - `scene-plugin.ts`: `scene = combine(Node.plugin, model, SceneUniforms.plugin)`
- `rendering/` (was `pbr/`): `pbr-core-plugin`, `ibl-render/`, `skinning/`,
  `standard-vertex/`, `visible-material/`, `rendering-plugin.ts` (aggregator)
- `camera/orbit/` (was `orbit/`): `orbit-data-plugin`, `orbit-system-plugin`,
  `orbit-plugin` (combiner), `attach-orbit-drag.ts`
- `animation/`: `animation-data-plugin` + `animation-plugin` (full)
- `gpu/` helpers moved into `rendering/ibl-render/ibl/` (only consumer)
- `vertices/` deleted (PositionColorNormalVertex was unused)

## Plugins

- `scene` now includes `SceneUniforms.plugin` — one import gives node + model +
  light + camera + GPU uniform packing
- `rendering` export added as intent-forward alias for `pbrIblRender`
- `pbrDirectRender` removed — `pbrIblRender` already has a procedural fallback
  when no `environmentUrl` is set
- `picking-plugin` split into `pickingBase` + `picking`; `PickingDB` derived via
  `Database.Plugin.ToStore` — no `any` in the ray scan impl
- `pickFromNdc` → `pickFromScreen({ x, y })` — takes pixel coords directly;
  NDC conversion happens internally with inline comments
- `setScareFromNdc` → `setScareFromScreen({ x, y, width, height })` — same
- `attachOrbitDrag` exported from `@adobe/data-gpu` — framework-agnostic DOM
  helper replaces the lit-specific orbit hook impl

## Type safety

- `pbr-core-plugin`: `null as unknown as GPU*` → `null as GPU* | null`
  (surfaced two latent null-safety bugs in ibl-render, now guarded)
- `model-loader-plugin`: `_bounds` default is `null as Aabb | null`
- `sample.ts`: return type `any` → `number | number[]`
- `compute-world-matrices`: invariant comments added to remaining casts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
krisnye and others added 28 commits June 4, 2026 16:05
Bridging physics bodies to the renderer is exactly when you want render-rate
interpolation, so the bridge now provides it — samples get smooth decoupled
physics with no extra plugin to remember (rigid-stack reverts to the plain
combine). interpolation stays exported for custom non-bridge render paths.

Also fix the clock/interpolation tests' store access to the loose-cast pattern
the solver benchmark uses (a created DB's writable store isn't on the public
Database type), so tsc -b passes alongside vitest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Kinematic bodies (bodyType "kinematic") are now mirrored as engine-kinematic
(Rapier kinematicPositionBased / Jolt EMotionType_Kinematic in the dynamic
layer) and driven to their authored position/rotation each step
(setNextKinematic* / MoveKinematic), so they push dynamics without being pushed
back. They render at the live authored pose (no prev-snapshot), and the
kinematic-drive query keys on that absence — distinguishing kinematic from
dynamic and static by archetype shape, no per-row value test.

rigid-stack gains a steel bar that sweeps through the bin, plowing the stack —
a visible side-by-side demo of kinematic→dynamic push on both solvers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add "capsule" to ColliderShape (Y-aligned: halfExtents.x = radius, .y =
cylinder half-height). Both solvers build it (Rapier capsule(halfHeight,radius)
/ Jolt CapsuleShape) and ColliderShape.massProperties gains the exact cylinder +
two-hemisphere inertia. Rendering: a capsule can't be non-uniformly scaled
without distorting its caps, so it's built at real size (capsuleMesh) and drawn
at unit scale — the bridge caches one mesh per distinct (radius, half-height).
Shared uploadShapeMesh helper. rigid-stack now drops boxes, spheres, and
tumbling capsules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dering)

Incremental 3D convex hull (convexHullMesh / hullFaces): seed a tetrahedron,
fold each point in by deleting visible faces and bridging the horizon, with
every face oriented outward via a fixed interior point (seed centroid) — robust
without half-edge bookkeeping. Produces a flat-shaded StandardVertex mesh so an
authored point-cloud convex collider can be rendered (Phase-2 auto-hull will
reuse it). Tested: tetra→4, octahedron→8, cube→12 (Euler), interior points
discarded, degenerate/coplanar→null.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add colliderShape "hull" + a runtime convexPoints component and a ConvexBody
archetype (dynamic/kinematic hulls; halfExtents unused). Both solvers build the
collision hull from the points (Rapier convexHull; Jolt ConvexHullShapeSettings),
the bridge builds the render mesh via convexHullMesh and caches it per point-array
reference (shared clouds share one mesh + instanced draw), drawn at unit scale.
rigid-stack now drops random convex polyhedra alongside boxes/capsules/spheres.

Authored point-cloud path (the chosen "points now" model); the "auto-hull from a
render Geometry" convenience is the deferred Phase 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add colliderShape "mesh" + a ColliderMesh type, a runtime colliderMesh component,
and a MeshCollider archetype (static only — a triangle soup has no interior).
Both solvers build the engine trimesh from the authored verts/indices (Rapier
trimesh; Jolt MeshShapeSettings via VertexList + IndexedTriangleList), the bridge
builds a flat-shaded render mesh (flatShadedMesh) cached per colliderMesh ref.
rigid-stack gains a static mesh ramp that dropped bodies land on and slide down.

Completes K3 Phase 1 (authored hull + mesh). Note: flat-shaded render meshes use
uint16 indices — fine for authored ramps/props, not yet dense terrain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…the stack

Enlarge the bin (BIN 7→12) and split it: the stable wood tower sits in the right
zone at +6.5; the sweeping kinematic bar + the mesh ramp are confined to the left
(sweep centre −4.5 ± 4.5), so the bar churns the dynamic pile without ever
reaching the stack. Drops now rain across the whole bin, so some still land on
and top the tower. Camera pulled back to frame the larger scene.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Retain model-space CPU positions+indices on Geometry (_cpuPositions/_cpuIndices,
aggregated over non-skinned primitives with node matrices baked). New
modelCollider plugin: a ModelBody / StaticModelCollider carries a geometry +
colliderShape hull|mesh but no collision data, and a postUpdate system fills it
once the mesh loads — hull → simplified hullVertices (engine rebuilds the hull),
mesh → the triangles verbatim — sourced from collisionGeometry ?? geometry, with
the model's scale baked in, cached per (geometry, shape, scale). Hand-authored
colliders still work (generation only runs when the data is absent).

Also link interpolation into the model render path: interpolateWorldMatrix
recomposes _worldMatrix from the interpolated pose (after transformSystem) so a
physics-driven model renders smoothly instead of at the stepped sim pose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l body

Both solvers now skip mirroring a hull/mesh body until its collision data exists,
so an auto-collider body whose source model is still loading isn't mirrored with a
placeholder shape — it's picked up the frame the data lands. rigid-stack drops a
downloaded DamagedHelmet (CC-BY 4.0) as three dynamic bodies: rendered in full
detail, colliding as a convex hull auto-generated from the mesh, on both solvers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a solver-agnostic jointData model (Joint archetype: type, two bodies,
local anchors, hinge axis + angle limits) and a JointType enum. Both solvers
mirror joints once both bodies exist (tag + exclude, like body sync): Rapier
fixed/revolute/spherical impulse joints; Jolt Fixed/Hinge/Point constraints
(world-space anchors mapped from the bodies' spawn pose). rigid-stack hangs a
chain of capsule links joined by point joints, anchored to a static box, that
the sweeper knocks around.

Add physics/README.md: a checkboxed feature roadmap — what's shipped (solvers,
body types, shapes, clock+interpolation, auto-colliders) and what's next,
including the single-hull-is-convex-approximation limitation and the ragdoll
path (per-bone colliders + this joints layer + a controller).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ities

Add a `cone` joint type: the bone axis is bound to a swing cone (half-angle
jointSwingLimit) around the reference axis, with a twist range
(jointMinLimit/jointMaxLimit) — the anatomical shoulder/hip limit ragdolls need.
Full on Jolt (SwingTwistConstraint); the Rapier compat binding has no cone
constraint, so it approximates `cone` as a free spherical (documented — use Jolt
for ragdoll limits). rigid-stack adds a cone-limited arm in a clear corner: on
Jolt it droops to the cone limit and leans; on Rapier it hangs straight down.

Also fix both solvers to apply a body's authored linearVelocity AND
angularVelocity at creation (Rapier set only linear before; Jolt set neither) —
correct behaviour, and needed for spun/launched bodies.

Roadmap: cone-twist limits checked off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…admap

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tep 1)

fitBoneCapsules: assigns each skinned vertex to its dominant bone, pushes that
bone's vertices into bind-local (via the joint's inverse-bind matrix), and fits
a capsule — longest local axis as the capsule axis, perpendicular spread as the
radius. Output is a bone-local offset (position + a rotation orienting the
Y-aligned capsule onto the fitted axis) + dims, so the capsule's world pose each
frame is jointWorldMatrix · offset (tracking the animated skeleton). Pure +
tested (X/Y-elongated boxes, per-bone grouping, sparse-bone skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oll step 1)

Retain CPU skin (mesh-bind positions + 4 joints + 4 weights) on skinned
Geometries. New boneColliders plugin: once a skeleton's skin loads it fits one
capsule per bone (fitBoneCapsules) and spawns a kinematic capsule body per bone;
each frame trackBoneColliders places it at jointWorldMatrix · offset, so the
capsules follow the animated skeleton (rendered over the character as a debug
proxy). Flipping them to dynamic + joints is the next step (the controller).

New `ragdoll` sample: CesiumMan (CC-BY 4.0) plays its walk clip while 19
auto-fitted capsules track its bones. Verified in-browser (capsules on the
limbs, animation cycling).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…olver

Add a collisionGroup component: bodies sharing the same non-zero group don't
collide with each other (they still hit the world) — honored by rapierSolver via
per-collider groups (Jolt support is a follow-up). Bone capsules get group 1, so
a ragdoll's bones never self-collide. The ragdoll sample now runs on rapierSolver
with a ground slab, and the model is lifted so the (coming) ragdoll drops onto it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…econcile

triggerRagdoll (on boneColliders): joints each bone capsule to its nearest
capsule-bearing ancestor with a point joint (anchored at the shared joint, from
the current pose), flips every capsule kinematic→dynamic, and stops the
animation. The Rapier solver performs the engine flip (setBodyType Dynamic +
prev-pose snapshot) when it sees a kinematic body's bodyType become dynamic.
reconcileRagdoll then writes each dynamic capsule's pose back onto its skeleton
joint (capsuleWorld · offset⁻¹, relative to the parent's capsule-derived world),
so the skinned mesh goes limp and flops; trackBoneColliders skips dynamic bones.

ragdoll sample: CesiumMan walks, then auto-ragdolls after 4s — 19 capsules flip,
18 joints hold it together (collisionGroup 1 prevents self-collision), it drops
onto the floor and the skin collapses with it. Verified in-browser.

Cone limits + Jolt flip/collision-groups are follow-ups (point joints + Rapier
for v1; see physics/README.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on Jolt)

The v1 ragdoll used point (ball) joints — no angular limits — so the limbs
folded freely and the body curled into a tight spiral. Fix it properly:

- joltSolver gains the kinematic→dynamic flip (SetMotionType) and a no-self-
  collide RAGDOLL object layer, so collisionGroup>0 bodies (a ragdoll's bones)
  collide with the world but not each other.
- the controller now jointing each bone with a cone (swing-twist) joint whose
  reference axis is the bone's current direction, limiting it to ~52° swing /
  ~±29° twist from rest — anatomical, not a free ball.
- the ragdoll sample runs on joltSolver (cone limits are Jolt-only; Rapier's
  binding has no cone constraint).

Verified in-browser: CesiumMan walks then collapses to a *spread* sprawl on the
floor (z-span ~1.4) instead of a tight spiral; no self-collision explosion, no
spinning, no errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ollTrigger

Split the ragdoll sample into two panels (like rigid-stack): a shared base scene
(model + floor + autoplay + auto-ragdoll, backend-agnostic) combined per solver.
Jolt shows the cone (swing-twist) limits; Rapier the free-ball ragdoll — an
honest side-by-side through the same skeleton.

Extract ragdollTrigger (the _ragdollTrigger flag + triggerRagdoll transaction)
into its own plugin so the base scene drives whichever backend is combined in —
setting up a Jolt-native Ragdoll backend to replace our generic one on the Jolt
panel. boneColliders now extends it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e with ours

Add joltRagdoll: a Jolt-native ragdoll using Skeleton / RagdollSettings /
Ragdoll / SkeletonPose. The joltSolver now publishes a _joltContext (jolt module
+ PhysicsSystem + the no-self-collide ragdoll layer) so the ragdoll is built in
the same world as the floor. Once the skin loads it builds one dynamic body per
bone (capsule from fitBoneCapsules, tiny sphere otherwise) + a swing-twist
to-parent constraint + DisableParentChildCollisions; while alive it
DriveToPoseUsingKinematics toward the animated pose, and on triggerRagdoll it
stops driving (bodies fall) and reads the pose back — moving the model to the
ragdoll root + applying local joint states so the whole skin flops.

The ragdoll sample now runs the two backends side by side: Jolt-native Ragdoll
vs our generic boneColliders (Rapier). ECS-opt: the SkeletonPose + Vec3/Quat
scratch are built once and reused each frame (no per-frame WASM allocation).

Verified in-browser: both walk then collapse onto the floor; the Jolt skeleton
settles at y~0.03-0.14; no errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eric)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	packages/data-react-hello/package.json
#	packages/data-solid-dashboard/package.json
#	packages/data/src/schema/index.ts
…tabase

vite-plugin-checker now type-checks the samples, surfacing pre-existing
errors in the p2p sample: the negotiation/presence bootstrap containers
fed the UI-restricted service to a controller and a streaming
async-generator transaction (both need the full database), and read a
non-existent `.sync` view for the peer id. Cast `this.service` to the
full database (it is the live database at runtime; the UIService
restriction only narrows the type for pure widgets) and read the peer
mark from `concurrency.userId`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… casts

Bootstrap containers (those that own a controller or drive a streaming
async-generator transaction) need the full transactional surface, not the
UI-restricted view. Previously DatabaseElement stored the live database
typed only as UIService.FromService, forcing every such container — and the
p2p sample — to cast (`as unknown as`, `as any`) to recover the real type.

Fix at the source: DatabaseElement now stores the live `database` with its
true `ToDatabase<P>` type (the DI target), and derives the restricted
`service` view through a new `UIService.restrict` helper. `restrict` performs
the sole full→restricted narrowing with zero casts via the overload-
implementation pattern (precise mapped return on the overload, broadened
`Service` implementation signature for the identity body). The narrowing is
sound by construction — `T` is always assignable to `FromService<T>` — TS
just cannot prove it for a deferred generic conditional.

Consumers are now cast-free: the negotiation/presence bootstrap containers
read `this.database`, the DI wrappers bind `.database`, and the peer id comes
from `concurrency.userId` (there is no `.sync` view). The restricted `service`
getter is unchanged for pure widgets; database-element.type-test still holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@krisnye krisnye merged commit 6e91963 into main Jun 10, 2026
3 checks passed
@krisnye krisnye deleted the krisnye/data-gpu branch June 10, 2026 05:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant