fix: spin voxel creatures around their own center, not the world origin
Browse filesThe live turntable rotated `mesh.rotation.y`, but the mesh sits at the
world origin while the camera aims at the bounding-box center. Asymmetric
archetypes (e.g. the horse, whose long body + front-protruding head puts
its centroid at z~2) orbited the origin as they spun and slid off-frame —
visibly jamming the horse against the right edge of its stage.
Wrap the mesh in a pivot Group at `center` with the mesh offset by
-center, and rotate/scale the pivot in all three render paths (live
turntable, reveal pop, static thumbnail). The creature now spins and
pops in place around its own center.
Also add wiki/voxel-rendering.md documenting the voxelizer, the
bounds-driven camera, the turntable pivot rule, and the thumbnail
shared-renderer context cap; link it from the wiki index.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- src/buddy_fusion/voxel_embed.py +15 -5
- wiki/index.md +2 -0
- wiki/log.md +7 -0
- wiki/voxel-rendering.md +91 -0
|
@@ -198,11 +198,21 @@ async function setupVoxel(root) {
|
|
| 198 |
scene.add(new THREE.HemisphereLight(0xffffff, 0x404060, 1.1));
|
| 199 |
const key = new THREE.DirectionalLight(0xffffff, 1.4); key.position.set(6, 12, 8); scene.add(key);
|
| 200 |
const cells = voxelsFromGenome(cfg.genome);
|
| 201 |
-
const mesh = voxelBuildMesh(THREE, cells);
|
| 202 |
// frame the whole creature (incl. legs/horns) with padding from its bounds.
|
| 203 |
const bb = new THREE.Box3().setFromObject(mesh);
|
| 204 |
const center = bb.getCenter(new THREE.Vector3());
|
| 205 |
const radius = bb.getSize(new THREE.Vector3()).length() * 0.7 + 3;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
// 3/4 top-down isometric-ish angle, Crossy-Road style.
|
| 207 |
cam.position.set(center.x + radius * 0.85, center.y + radius * 0.62, center.z + radius * 0.85);
|
| 208 |
cam.lookAt(center);
|
|
@@ -212,7 +222,7 @@ async function setupVoxel(root) {
|
|
| 212 |
// One WebGL context per thumbnail would blow past the browser's ~16-context
|
| 213 |
// cap as the family tree grows and silently blank the oldest stages; a
|
| 214 |
// single shared context keeps the total flat regardless of tree size.
|
| 215 |
-
|
| 216 |
const shared = voxelThumbRenderer(THREE);
|
| 217 |
shared.setSize(W, H);
|
| 218 |
shared.render(scene, cam);
|
|
@@ -236,9 +246,9 @@ async function setupVoxel(root) {
|
|
| 236 |
function loop(now) {
|
| 237 |
if (!root.isConnected) { renderer.dispose(); if (renderer.forceContextLoss) renderer.forceContextLoss(); return; }
|
| 238 |
const dt = Math.min(0.05, (now - t0)/1000); t0 = now;
|
| 239 |
-
|
| 240 |
-
if (reveal < 1) { reveal = Math.min(1, reveal + dt*1.1);
|
| 241 |
-
else {
|
| 242 |
controls.update();
|
| 243 |
renderer.render(scene, cam);
|
| 244 |
requestAnimationFrame(loop);
|
|
|
|
| 198 |
scene.add(new THREE.HemisphereLight(0xffffff, 0x404060, 1.1));
|
| 199 |
const key = new THREE.DirectionalLight(0xffffff, 1.4); key.position.set(6, 12, 8); scene.add(key);
|
| 200 |
const cells = voxelsFromGenome(cfg.genome);
|
| 201 |
+
const mesh = voxelBuildMesh(THREE, cells);
|
| 202 |
// frame the whole creature (incl. legs/horns) with padding from its bounds.
|
| 203 |
const bb = new THREE.Box3().setFromObject(mesh);
|
| 204 |
const center = bb.getCenter(new THREE.Vector3());
|
| 205 |
const radius = bb.getSize(new THREE.Vector3()).length() * 0.7 + 3;
|
| 206 |
+
// Spin/scale around the creature's OWN center, not the world origin. Most
|
| 207 |
+
// archetypes are asymmetric in the horizontal plane (the horse's long body +
|
| 208 |
+
// front-protruding head puts its centroid well off z=0); rotating the mesh
|
| 209 |
+
// about the origin would orbit that centroid and slide the creature off-frame.
|
| 210 |
+
// A pivot at `center` with the mesh offset by -center keeps it centered.
|
| 211 |
+
const pivot = new THREE.Group();
|
| 212 |
+
mesh.position.sub(center);
|
| 213 |
+
pivot.position.copy(center);
|
| 214 |
+
pivot.add(mesh);
|
| 215 |
+
scene.add(pivot);
|
| 216 |
// 3/4 top-down isometric-ish angle, Crossy-Road style.
|
| 217 |
cam.position.set(center.x + radius * 0.85, center.y + radius * 0.62, center.z + radius * 0.85);
|
| 218 |
cam.lookAt(center);
|
|
|
|
| 222 |
// One WebGL context per thumbnail would blow past the browser's ~16-context
|
| 223 |
// cap as the family tree grows and silently blank the oldest stages; a
|
| 224 |
// single shared context keeps the total flat regardless of tree size.
|
| 225 |
+
pivot.rotation.y = 0.7;
|
| 226 |
const shared = voxelThumbRenderer(THREE);
|
| 227 |
shared.setSize(W, H);
|
| 228 |
shared.render(scene, cam);
|
|
|
|
| 246 |
function loop(now) {
|
| 247 |
if (!root.isConnected) { renderer.dispose(); if (renderer.forceContextLoss) renderer.forceContextLoss(); return; }
|
| 248 |
const dt = Math.min(0.05, (now - t0)/1000); t0 = now;
|
| 249 |
+
pivot.rotation.y += dt * 0.5;
|
| 250 |
+
if (reveal < 1) { reveal = Math.min(1, reveal + dt*1.1); pivot.scale.setScalar(0.15 + 0.85*voxelEaseReveal(reveal)); }
|
| 251 |
+
else { pivot.scale.setScalar(1); }
|
| 252 |
controls.update();
|
| 253 |
renderer.render(scene, cam);
|
| 254 |
requestAnimationFrame(loop);
|
|
@@ -16,6 +16,8 @@ See [README](README.md) for how this wiki works.
|
|
| 16 |
design bet, components, data flow, deployment, and hard-won gotchas.
|
| 17 |
- [local-dev-runtimes](local-dev-runtimes.md) - Fake vs real runtime boundary,
|
| 18 |
which mode exercises what, and the local CPU caveat.
|
|
|
|
|
|
|
| 19 |
|
| 20 |
## Integrations
|
| 21 |
|
|
|
|
| 16 |
design bet, components, data flow, deployment, and hard-won gotchas.
|
| 17 |
- [local-dev-runtimes](local-dev-runtimes.md) - Fake vs real runtime boundary,
|
| 18 |
which mode exercises what, and the local CPU caveat.
|
| 19 |
+
- [voxel-rendering](voxel-rendering.md) - The Three.js voxelizer: recipe→cells,
|
| 20 |
+
bounds-driven camera, turntable/reveal pivot, and the thumbnail context cap.
|
| 21 |
|
| 22 |
## Integrations
|
| 23 |
|
|
@@ -2,6 +2,13 @@
|
|
| 2 |
|
| 3 |
## 2026-06-14
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
### Ported AI/dev tooling from build-a-buddy-hf
|
| 6 |
|
| 7 |
- Added `CLAUDE.md` (workflow rules: English-only, never-auto-push to the Space,
|
|
|
|
| 2 |
|
| 3 |
## 2026-06-14
|
| 4 |
|
| 5 |
+
### Voxel rendering page + turntable centering fix
|
| 6 |
+
|
| 7 |
+
- Added `voxel-rendering.md` documenting the voxelizer, bounds-driven camera,
|
| 8 |
+
the thumbnail shared-renderer context cap, and the turntable pivot rule.
|
| 9 |
+
- Recorded the fix where the turntable rotated about the world origin instead of
|
| 10 |
+
the creature's centroid, sliding asymmetric archetypes (horse) off-frame.
|
| 11 |
+
|
| 12 |
### Ported AI/dev tooling from build-a-buddy-hf
|
| 13 |
|
| 14 |
- Added `CLAUDE.md` (workflow rules: English-only, never-auto-push to the Space,
|
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Voxel Rendering (the Three.js voxelizer)
|
| 3 |
+
tags: [architecture, debugging, frontend, three-js, rendering]
|
| 4 |
+
last_updated: 2026-06-14
|
| 5 |
+
confidence: high
|
| 6 |
+
sources:
|
| 7 |
+
- src/buddy_fusion/voxel_embed.py
|
| 8 |
+
related_pages:
|
| 9 |
+
- gradio-embed-integration.md
|
| 10 |
+
- system-overview.md
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Voxel Rendering (the Three.js voxelizer)
|
| 14 |
+
|
| 15 |
+
## Summary
|
| 16 |
+
|
| 17 |
+
How a genome becomes the cube creature on screen, and the rendering invariants
|
| 18 |
+
that keep it readable and centered. The voxelizer lives entirely in
|
| 19 |
+
`VOXEL_SETUP_JS` inside `src/buddy_fusion/voxel_embed.py` (delivered via
|
| 20 |
+
`js_on_load`; see [gradio-embed-integration](gradio-embed-integration.md) for how
|
| 21 |
+
the embed boots and re-inits). Read this before touching archetype geometry, the
|
| 22 |
+
camera, the turntable, or the reveal animation.
|
| 23 |
+
|
| 24 |
+
## Key Details
|
| 25 |
+
|
| 26 |
+
- **Recipe → geometry, never raw voxels.** The model only picks the recipe
|
| 27 |
+
(`archetype` + `parts` + `palette`). `voxelsFromGenome(g)` turns that into a
|
| 28 |
+
flat list of `{x, y, z, color}` cells. `VOXEL_ARCH` holds the per-archetype
|
| 29 |
+
body-plan templates (body/legs/head dims, `headMode`, `feat`); `g.parts` layer
|
| 30 |
+
extra/mutated features (leg/horn/wing/tail/arm/eye/second-head) on top.
|
| 31 |
+
- **Cell coordinate convention.** `box(cx,cy,cz,w,h,d,color)` centers a block on
|
| 32 |
+
`(cx, cz)` in the horizontal plane (`x - (w-1)/2`, `z - (d-1)/2`) but grows
|
| 33 |
+
**up** from `cy` (`cy + y`, no vertical centering) — y=0 is the ground, legs
|
| 34 |
+
start there, `bodyY = legLen`. Keep this convention when adding parts or the
|
| 35 |
+
feature will float / sink.
|
| 36 |
+
- **One InstancedMesh per creature.** All cells render as instances of a single
|
| 37 |
+
`BoxGeometry(0.96³)` + `MeshStandardMaterial` (the 0.96 leaves a seam so cubes
|
| 38 |
+
read as discrete). Per-instance color via `setColorAt`. This keeps a creature
|
| 39 |
+
to one draw call regardless of voxel count.
|
| 40 |
+
- **Camera framing is bounds-driven.** After building the mesh, a
|
| 41 |
+
`Box3.setFromObject` gives `center` + `radius`; the camera sits at a fixed 3/4
|
| 42 |
+
top-down isometric offset (`center + radius·(0.85, 0.62, 0.85)`) and
|
| 43 |
+
`lookAt(center)`. So the framing auto-fits any creature size without per-archetype
|
| 44 |
+
tuning.
|
| 45 |
+
- **Reveal animation.** `voxelEaseReveal` is an ease-out-back (overshoots past
|
| 46 |
+
1.0 then settles) driving `scale` from 0.15→1 for the "pop" when a spliced
|
| 47 |
+
child appears (`autoplay_reveal=True`).
|
| 48 |
+
|
| 49 |
+
## Failure Modes
|
| 50 |
+
|
| 51 |
+
- **Turntable must spin around the creature's own center, not the world origin.**
|
| 52 |
+
The bug (fixed 2026-06-14): the live stages rotate the creature as a turntable.
|
| 53 |
+
Rotating `mesh.rotation.y` spins about the mesh's local origin, which is the
|
| 54 |
+
**world origin (0,0,0)** — but most archetypes are asymmetric in the
|
| 55 |
+
horizontal plane (e.g. `horse` has `body:[4,4,7]` plus a `headMode:"front"`
|
| 56 |
+
head + snout protruding to z≈7, so its centroid sits around z≈2, well off the
|
| 57 |
+
origin). Spinning an off-origin centroid makes it **orbit** the origin and
|
| 58 |
+
slide out of frame — the horse visibly jammed to the right edge while the more
|
| 59 |
+
symmetric chick stayed put. The camera aims at the fixed initial `center`, so
|
| 60 |
+
the drift is purely the rotation pivot.
|
| 61 |
+
**Fix:** wrap the mesh in a pivot `Group` positioned at `center`, offset the
|
| 62 |
+
mesh by `-center`, and rotate/scale the **pivot** instead of the mesh. The
|
| 63 |
+
geometry then spins (and the reveal scales) in place around its own center.
|
| 64 |
+
Apply it in all three render paths — the live turntable rotation, the reveal
|
| 65 |
+
scale, and the static thumbnail frame (`pivot.rotation.y = 0.7`).
|
| 66 |
+
Detection: watch a tall/long archetype (horse, hippo, dog) through a full
|
| 67 |
+
turn; if it swings toward an edge at some angles instead of rotating in place,
|
| 68 |
+
the pivot is at the origin, not the center.
|
| 69 |
+
- **WebGL context cap (~16) — thumbnails must share one renderer.** Each live
|
| 70 |
+
stage holds a `WebGLRenderer`/context; the family tree grows one node per
|
| 71 |
+
creature, so a live context per thumbnail would blow the browser cap and blank
|
| 72 |
+
the oldest stages. Thumbnails (`voxel_html(thumb=True)`) render through one
|
| 73 |
+
shared offscreen renderer (`window.__voxelThumbRenderer`,
|
| 74 |
+
`preserveDrawingBuffer:true`) and snapshot to an `<img>` — flat context count
|
| 75 |
+
regardless of tree size. Only the two ancestor stages + the child stage keep a
|
| 76 |
+
live context. See [gradio-embed-integration](gradio-embed-integration.md).
|
| 77 |
+
- **Detached live stages leak a context if not disposed.** The animation loop
|
| 78 |
+
guards on `root.isConnected`; when a stage leaves the DOM it calls
|
| 79 |
+
`renderer.dispose()` + `forceContextLoss()` and stops the RAF. Skipping this
|
| 80 |
+
leaks a context per re-render and eventually trips the cap above.
|
| 81 |
+
|
| 82 |
+
## Sources
|
| 83 |
+
|
| 84 |
+
- `src/buddy_fusion/voxel_embed.py` (`VOXEL_ARCH`, `voxelsFromGenome`,
|
| 85 |
+
`voxelBuildMesh`, `setupVoxel`, the pivot-centering fix, the shared thumbnail
|
| 86 |
+
renderer)
|
| 87 |
+
|
| 88 |
+
## Related Pages
|
| 89 |
+
|
| 90 |
+
- [gradio-embed-integration](gradio-embed-integration.md)
|
| 91 |
+
- [system-overview](system-overview.md)
|