Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,9 @@ Needs `git`, `ffmpeg`/`ffprobe` with libx264, and `python3` with Pillow and nump

After building, `bun run serve` serves `/demos/head-turn/` from the isolated output. The integration entry uses `<base href="./bundle/">`, with separate `site-ui.css` / `site-ui.js` for its single preview caption and Controls toggle. Below 800 CSS pixels the controls start closed; desktop keeps them available. Loading status belongs to the original application and remains visible until it finishes. The preserved demo caption predates the Parts 1.68.0 producer release. The Examples and Parts pages distinguish that standalone snapshot from the released producer; this documentation update does not rebuild or change its interactive files.

`demos/head-turn/bundle/` contains the approved ten-file preview inventory. The 2026-10-26 depth revision replaces `depth.jpg` and re-bakes `scene.json` / `scene.bin.gz` from that same confirmed atlas; the bundle README records the input and output hashes. The field reader, scene extractor and exact tuple deduplicator first reproduced the previous fields and complete gzip bytes as a control. The revision preserves ordered triangle-expanded neutral positions and UVs, all 224,144 triangles, draw order, eye aperture/core data and controller parameters. Depth-dependent deduplication now stores 132,540 vertices instead of 132,534. `index.html`, `app.js`, `aperture.js`, `runtime.js`, `look.png` and `THIRD_PARTY_LICENSES.txt` retain their previous bytes, as do the integration UI and separate curvature page. The adjacent `bundle-manifest.json` pins the revised inventory's path-sorted lengths and SHA-256 values; the build checks its digest before fetching registry data. Verification checks the whole copied `demos/` tree and the complete deployment manifest.
`demos/head-turn/bundle/` contains the approved ten-file preview inventory. The 2026-10-26 depth revision replaced `depth.jpg` and re-baked `scene.json` / `scene.bin.gz` from that same confirmed atlas. The field reader, scene extractor and exact tuple deduplicator first reproduced the previous fields and complete gzip bytes as a control. It preserved ordered triangle-expanded neutral positions and UVs, all 224,144 triangles, draw order, eye aperture/core data and controller parameters; depth-dependent deduplication stores 132,540 vertices.

The 2026-10-28 GPU revision moves depth displacement from the frame callback into a Pixi 8.22.0 mesh vertex shader. It prepares and uploads each mesh's base/UV/depth/index buffers once, then updates dial and gaze uniforms while retaining the original eye core clamp and aperture sprites. The controls, defaults, source geometry, atlases, entry and curvature page stay unchanged. The bundle README records source API links and the input/output SHA-256 rows. The adjacent `bundle-manifest.json` pins the ten files' path-sorted lengths and hashes; `HEAD_TURN_BUNDLE_SHA256` independently pins that inventory. The build checks it before fetching registry data; verification checks the whole copied `demos/` tree and the complete deployment manifest.

`demos/head-turn/curvature/` is a separate static page linked from the preview's caption: one WebGL fragment shader samples `original.webp` at each pixel's position plus depth × camera offset (pointer on desktop, drag on touch), with depth gamma, contrast and bias and a Show depth view. It reads the depth map and is not the head-turn feature. `depth.webp` is the supplied 1359×2000 depth map resampled into the painting's 1368×2000 frame by `x_depth = 0.99908·x − 2.939` (pixel indices, y unchanged), fitted to the silhouette edges of both images; a uniform 1359/1368 stretch left a median edge residual of 2.77 px, this fit 0.17 px. `verifyCurvaturePage` (in `src/head-turn.ts`) checks its five files, caption, back link, that every reference is relative and resolves, and the preview's link to it.

Expand Down
14 changes: 7 additions & 7 deletions demos/head-turn/bundle-manifest.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"schema": 1,
"sha256": "515c89f1307308e5e41dcfe5a0fc7c69e5751539863ef7cb7c14e1cf6ffa38e2",
"sha256": "fb7bc15b188ed8f29a931067df44a4c3cdb61ba3f114c706722eee73c8a6c2fc",
"files": [
{
"path": "README.md",
"bytes": 3979,
"sha256": "4b0cb0bc44ccbb56674d3cfd2b149b79b10722989fb964fc273345574d3b79d7"
"bytes": 8823,
"sha256": "6b30d00f969e6201ba2b45db73123fd3c419d710b797ca1b58f699cc47aaa626"
},
{
"path": "THIRD_PARTY_LICENSES.txt",
Expand All @@ -19,8 +19,8 @@
},
{
"path": "app.js",
"bytes": 5410,
"sha256": "e2c3b1dff5a4efa14ed66498f693638c62cbb996db8d3be00a5ba6c17504b2d6"
"bytes": 7940,
"sha256": "4e2536d43f8ad7ef002ecaa65350c1cec92d24d3f809f2fd6299f21e6fb0e2ca"
},
{
"path": "depth.jpg",
Expand All @@ -39,8 +39,8 @@
},
{
"path": "runtime.js",
"bytes": 486531,
"sha256": "33551eef39683c12a0c3e340acba981f09a1fa4e0141c2d55dd02f9bbd269d54"
"bytes": 486648,
"sha256": "f55d9bda6ea33736753781895f2ab4c87a6b49e04ac4b40622c7ab10e3a0f0d4"
},
{
"path": "scene.bin.gz",
Expand Down
33 changes: 33 additions & 0 deletions demos/head-turn/bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,36 @@ SHA-256 provenance:
The gzip recipe is `gzip.compress(raw, compresslevel=9, mtime=0)`, reproduced with Python 3.13.1 and zlib 1.2.12 (compile and runtime). This is a reproduced invocation, not a recovered historical command log. The scene finalization changes only its `buffer` name from `scene.bin` to `scene.bin.gz`; gzip headers are not patched. The decoded payload is 6,294,257 bytes; its raw gzip file is 3,405,993 bytes.

All 19 parts retain their ordered triangle-expanded Float64 base positions and UVs, 224,144 ordered triangles, common/iris drivers, eye masks and core data, bounds, basis and gain. Exact depth-dependent tuple deduplication increases the stored vertex count from 132,534 to 132,540. Packed indices and offsets may consequently differ. The renderer, gaze controller, colour atlas, original entry and runtime licence notices retain their previous bytes.

## GPU displacement revision (2026-10-28)

The static meshes, UVs, triangle indices and added `aDepth` attributes are uploaded once when the page loads. The frame callback updates typed shader uniforms for the depth rotation, gain, pivot, rear-hair common motion and bounded gaze. `PixiRenderer.render` prepares the neutral meshes once; retaining them with custom shaders avoids the adapter's CPU vertex scan and batch packing on subsequent frames. The four controls, default values, pointer damping and 1.5× canvas sizing are unchanged.

The vertex shader evaluates `z = depth * depthGain * gain`, `dx = z * sin(yaw)` and `dy = z * sin(pitch)`, then adds the original basis transform, `common * pivot * sin` and the iris gaze shift. `common` is `-counter` for rear hair, `1` for common head parts and `0` otherwise. World y is negated for the existing Pixi coordinate convention. The unchanged `aperture.js` clamps gaze against the same core masks on the CPU; the two aperture sprites retain the original matrix formula. The constant-depth iris bounds follow the displaced iris with four scalar updates so Pixi's alpha filter still covers it. No per-frame vertex buffer writes are needed.

`runtime.js` identifies its bundled Pixi as **8.22.0** (`pr="8.22.0"`, passed to `__PIXI_RENDERER_INIT__`). This revision only appends exports for its existing `Shader` (`Xt`) and `GlProgram` (`Gt`); it does not replace or rebuild Pixi. The API follows the versioned [Shader documentation](https://pixijs.download/v8.22.0/docs/rendering.Shader.html) and [Geometry.addAttribute documentation](https://pixijs.download/v8.22.0/docs/rendering.Geometry.html). Pixi's [MeshPipe](https://github.com/pixijs/pixijs/blob/v8.22.0/src/scene/mesh/shared/MeshPipe.ts) supplies local transform/color uniforms; [GlMeshAdaptor](https://github.com/pixijs/pixijs/blob/v8.22.0/src/scene/mesh/gl/GlMeshAdaptor.ts) supplies global and local bind groups to custom shaders. This page continues to use WebGL.

Additional SHA-256 provenance (the scene, atlases and aperture controller retain the depth revision's bytes):

| Input or output | SHA-256 |
| --- | --- |
| CPU `app.js` input | `e2c3b1dff5a4efa14ed66498f693638c62cbb996db8d3be00a5ba6c17504b2d6` |
| Bundled Pixi runtime input | `33551eef39683c12a0c3e340acba981f09a1fa4e0141c2d55dd02f9bbd269d54` |
| Initial ES300 GPU `app.js` (pre-repair) | `89aec03bc57f3363a7a37942bd3309486341a11f533626b55958e9080311a59c` |
| Runtime with shader exports | `f55d9bda6ea33736753781895f2ab4c87a6b49e04ac4b40622c7ab10e3a0f0d4` |
| Unchanged aperture controller | `b2c159cdd338649ad6833cfd04bb710da6e106d76655c71d3a9ab7c57cbd543c` |

Visual parity and callback timing require a browser run against the frozen CPU reference. Bundle integrity tests alone do not establish either result. GPU arithmetic uses Float32, so the visual comparison must state its pixel error bounds rather than assume bit-exact CPU Float64 arithmetic.

## WebGL1 compatibility repair (2026-10-28)

The initial GPU shaders explicitly requested GLSL ES300 and therefore failed on the original renderer's WebGL1 fallback. This repair removes the two `#version 300 es` lines; the displacement, uniforms, mask/controller code and bundled runtime are unchanged.

The exact bundled mechanism is `id`/`Gt` (`GlProgram`), which tests `fragment.indexOf("#version 300 es") !== -1` and passes that boolean to `addProgramDefines` and `insertVersion`. With no header, `Yu` (`addProgramDefines`) maps vertex `in`/`out` to `attribute`/`varying`, maps fragment `in` to `varying`, removes the exact `out vec4 finalColor;` declaration and maps `finalColor`/`texture` to `gl_FragColor`/`texture2D`. `Zu` (`insertVersion`) returns the source without adding an ES300 header. Precision handling remains in `qu` (`ensurePrecision`); highp is requested as before. This uses the shared GLSL100 compatibility path on WebGL1 and WebGL2. See the version-pinned [GlProgram constructor](https://github.com/pixijs/pixijs/blob/v8.22.0/src/rendering/renderers/gl/shader/GlProgram.ts#L134-L164), [addProgramDefines](https://github.com/pixijs/pixijs/blob/v8.22.0/src/rendering/renderers/gl/shader/program/preprocessors/addProgramDefines.ts#L6-L32), and [insertVersion](https://github.com/pixijs/pixijs/blob/v8.22.0/src/rendering/renderers/gl/shader/program/preprocessors/insertVersion.ts). The same control flow and replacements are present in the minified runtime.

| Repair input or output | SHA-256 |
| --- | --- |
| Pre-repair GPU app | `89aec03bc57f3363a7a37942bd3309486341a11f533626b55958e9080311a59c` |
| Original CPU reference | `e2c3b1dff5a4efa14ed66498f693638c62cbb996db8d3be00a5ba6c17504b2d6` |
| Compatible GPU app | `4e2536d43f8ad7ef002ecaa65350c1cec92d24d3f809f2fd6299f21e6fb0e2ca` |
| Unchanged bundled runtime | `f55d9bda6ea33736753781895f2ab4c87a6b49e04ac4b40622c7ab10e3a0f0d4` |
70 changes: 65 additions & 5 deletions demos/head-turn/bundle/app.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import{PixiRenderer,WebGLRenderer,Container,Sprite,Texture,Rectangle,Matrix}from'./runtime.js';
import{PixiRenderer,WebGLRenderer,Container,Sprite,Texture,Rectangle,Matrix,Shader,GlProgram}from'./runtime.js';
import{coreAnchorClamp}from'./aperture.js';
const $=id=>document.getElementById(id),host=$('demo'),image=new Image();image.src=new URL('./look.png',import.meta.url);
try{
Expand All @@ -11,13 +11,73 @@ try{
const stage=new Container(),root=new Container(),masks=new Container();stage.addChild(root,masks);const painter=new PixiRenderer(root,{pageImages:new Map([['look.png',image]])});let width,height,scale,cameraX,cameraY;
function fit(){width=Math.max(1,Math.round(innerWidth*1.5));height=Math.max(1,Math.round(innerHeight*1.5));renderer.resize(width,height);const b=scene.bounds;scale=Math.min(width*.82/b.width,height*.88/b.height);cameraX=width/2-(b.x+b.width/2)*scale;cameraY=height/2+(b.y+b.height/2)*scale;for(const c of[root,masks]){c.scale.set(scale);c.position.set(cameraX,cameraY)}}fit();
let gain=1,pivot=55,gaze=.10,counter=.10,x=0,y=0,vx=0,vy=0,tx=0,ty=0;
// Pixi 8.22.0: retain the meshes and their buffers; a custom shader disables batching.
const frame={order:parts.map(p=>p.slot)},maskSprites=[];
painter.render(frame);
const vertex=`
in vec2 aPosition;
in vec2 aUV;
in float aDepth;
uniform mat3 uProjectionMatrix;
uniform mat3 uWorldTransformMatrix;
uniform mat3 uTransformMatrix;
uniform vec4 uTurn;
uniform vec4 uBasis;
uniform float uCommon;
uniform vec2 uGaze;
out vec2 vUV;
void main(){
float z=aDepth*uTurn.z;
float dx=z*uTurn.x,dy=z*uTurn.y;
vec2 displacement=vec2(uBasis.x*dx+uBasis.y*dy,uBasis.z*dx+uBasis.w*dy)
+uCommon*uTurn.w*uTurn.xy+uGaze;
// PixiRenderer stores world y with its sign flipped.
vec2 position=aPosition+vec2(displacement.x,-displacement.y);
mat3 mvp=uProjectionMatrix*uWorldTransformMatrix*uTransformMatrix;
gl_Position=vec4((mvp*vec3(position,1.0)).xy,0.0,1.0);
vUV=aUV;
}`;
const fragment=`
in vec2 vUV;
uniform sampler2D uTexture;
uniform vec4 uColor;
uniform vec4 uWorldColorAlpha;
out vec4 finalColor;
void main(){finalColor=texture(uTexture,vUV)*uColor*uWorldColorAlpha;}`;
const glProgram=GlProgram.from({vertex,fragment,name:'depth-head-turn',preferredFragmentPrecision:'highp'});
const turn=new Float32Array(4),basis=new Float32Array([scene.basis.a,scene.basis.b,scene.basis.c,scene.basis.d]);
parts.forEach((p,i)=>{
const mesh=root.children[i],depth=new Float32Array(p.base.length/2);
if(typeof p.z==='number')depth.fill(p.z);else depth.set(p.z);
mesh.geometry.addAttribute('aDepth',{buffer:depth,format:'float32'});
mesh.shader=new Shader({glProgram,resources:{uTexture:mesh.texture.source,depthUniforms:{
uTurn:{value:turn,type:'vec4<f32>'},uBasis:{value:basis,type:'vec4<f32>'},
uCommon:{value:0,type:'f32'},uGaze:{value:new Float32Array(2),type:'vec2<f32>'}
}}});
p.mesh=mesh;p.uniforms=mesh.shader.resources.depthUniforms.uniforms;
// Alpha-mask filter bounds must follow the displaced iris too. Its depth is constant;
// update four bounds scalars, without touching any vertex or GPU buffer.
if(p.iris>=0){if(typeof p.z!=='number')throw Error('Iris requires constant depth');
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
for(let j=0;j<p.base.length;j+=2){minX=Math.min(minX,p.base[j]);maxX=Math.max(maxX,p.base[j]);minY=Math.min(minY,p.base[j+1]);maxY=Math.max(maxY,p.base[j+1])}
p.irisBounds=[minX,minY,maxX,maxY];
}
});
scene.eyes.forEach((e,i)=>{
const sprite=new Sprite(new Texture({source:painter.getPageSource('look.png'),frame:new Rectangle(...e.frame)}));
masks.addChild(sprite);maskSprites[i]=sprite;
root.children[e.iris].setMask({mask:sprite,channel:'alpha',inverse:false});
});
function draw(){const sx=Math.sin(x*15*Math.PI/180),sy=Math.sin(y*12*Math.PI/180),strength=scene.depthGain*gain,T=[pivot*sx,pivot*sy],b=scene.basis;
turn[0]=sx;turn[1]=sy;turn[2]=strength;turn[3]=pivot;
const eyeShift=scene.eyes.map(e=>coreAnchorClamp(e.core,e.size[0],e.size[1],e.anchor,[gaze*e.size[0]*x,-gaze*e.size[0]*y]).shift);
for(const p of parts){const a=p.slot.vertices=new Array(p.base.length),common=p.common===2?-counter:p.common===1?1:0,e=p.iris>=0?scene.eyes[p.iris]:null,shift=e?eyeShift[p.iris]:[0,0],gx=e?e.gazeBasis[0]*shift[0]+e.gazeBasis[1]*shift[1]:0,gy=e?e.gazeBasis[2]*shift[0]+e.gazeBasis[3]*shift[1]:0;
for(let i=0;i<p.base.length/2;i++){const z=(typeof p.z==='number'?p.z:p.z[i])*strength,dx=z*sx,dy=z*sy;a[2*i]=p.base[2*i]+b.a*dx+b.b*dy+common*T[0]+gx;a[2*i+1]=p.base[2*i+1]+b.c*dx+b.d*dy+common*T[1]+gy}}
root.removeChildren();painter.render(frame);
scene.eyes.forEach((e,i)=>{let sprite=maskSprites[i];if(!sprite){sprite=new Sprite(new Texture({source:painter.getPageSource('look.png'),frame:new Rectangle(...e.frame)}));masks.addChild(sprite);maskSprites[i]=sprite}const p=parts[e.unit],z=p.z*strength,dx=z*sx,dy=z*sy,m=e.base;sprite.setFromMatrix(new Matrix(m[0],m[1],m[2],m[3],m[4]+T[0]+b.a*dx+b.b*dy,m[5]-(T[1]+b.c*dx+b.d*dy)));root.children[e.iris].setMask({mask:sprite,channel:'alpha',inverse:false})});
for(const p of parts){const common=p.common===2?-counter:p.common===1?1:0,e=p.iris>=0?scene.eyes[p.iris]:null,shift=e?eyeShift[p.iris]:[0,0],gx=e?e.gazeBasis[0]*shift[0]+e.gazeBasis[1]*shift[1]:0,gy=e?e.gazeBasis[2]*shift[0]+e.gazeBasis[3]*shift[1]:0;
p.uniforms.uCommon=common;p.uniforms.uGaze[0]=gx;p.uniforms.uGaze[1]=gy;
if(p.irisBounds){const z=p.z*strength,dx=b.a*(z*sx)+b.b*(z*sy)+common*T[0]+gx,dy=b.c*(z*sx)+b.d*(z*sy)+common*T[1]+gy,q=p.irisBounds;
p.mesh.geometry.bounds.set(Math.fround(q[0]+dx),Math.fround(-q[3]-dy),Math.fround(q[2]+dx),Math.fround(-q[1]-dy));p.mesh.onViewUpdate();
}
}
scene.eyes.forEach((e,i)=>{const sprite=maskSprites[i],p=parts[e.unit],z=p.z*strength,dx=z*sx,dy=z*sy,m=e.base;sprite.setFromMatrix(new Matrix(m[0],m[1],m[2],m[3],m[4]+T[0]+b.a*dx+b.b*dy,m[5]-(T[1]+b.c*dx+b.d*dy)))});
renderer.render({container:stage});
}
const finite=(v,min,max)=>{if(!Number.isFinite(v))throw Error('Control must be finite');return Math.max(min,Math.min(max,v))};
Expand Down
3 changes: 3 additions & 0 deletions demos/head-turn/bundle/runtime.js

Large diffs are not rendered by default.

17 changes: 14 additions & 3 deletions src/head-turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@ import { artifactManifest } from './deployment.ts';
import { CURVATURE_PATH, HEAD_TURN_PATH, verifyCurvaturePage, verifyHeadTurnBundle } from './head-turn.ts';
const read = (path: string) => readFileSync(join(ROOT_DIR, path), 'utf8');

test('approved depth revision bundle is byte exact, including original entry and raw gzip', () => {
test('approved GPU bundle is byte exact, including original entry and raw gzip', () => {
verifyHeadTurnBundle(ROOT_DIR);
const gzip = readFileSync(join(ROOT_DIR, HEAD_TURN_PATH, 'bundle/scene.bin.gz'));
expect([...gzip.subarray(0, 2)]).toEqual([31, 139]);
});

test('changed depth image or compiled depth payload fails the approved inventory', () => {
test('changed geometry, shader, runtime or controller fails the approved inventory', () => {
const copy = mkdtempSync(join(tmpdir(), 'head-turn-depth-'));
try {
const demo = join(copy, HEAD_TURN_PATH);
cpSync(join(ROOT_DIR, HEAD_TURN_PATH, 'bundle'), join(demo, 'bundle'), { recursive: true });
cpSync(join(ROOT_DIR, HEAD_TURN_PATH, 'bundle-manifest.json'), join(demo, 'bundle-manifest.json'));
for (const name of ['depth.jpg', 'scene.bin.gz']) {
for (const name of ['depth.jpg', 'scene.bin.gz', 'app.js', 'runtime.js', 'aperture.js']) {
const path = join(demo, 'bundle', name);
const original = readFileSync(path);
const changed = Buffer.from(original);
Expand Down Expand Up @@ -90,3 +90,14 @@ test('curvature check verification names a missing file and a broken reference',
rmSync(copy, { recursive: true, force: true });
}
});

// The real WebGL1/2 compilation and pixel comparison run in the browser gate.
// This source contract rejects the explicit ES300 header that bypassed Pixi's compatibility path.
test('head-turn shader leaves GLSL compatibility preprocessing to the bundled Pixi', () => {
const app = read(`${HEAD_TURN_PATH}/bundle/app.js`);
const shaders = [...app.matchAll(/const (vertex|fragment)=`([\s\S]*?)`;/g)];
expect(shaders.map((match) => match[1])).toEqual(['vertex', 'fragment']);
for (const shader of shaders) expect(shader[2]).not.toMatch(/#\s*version/);
// addProgramDefines consumes this exact output declaration and maps finalColor.
expect(shaders[1]![2]).toContain('out vec4 finalColor;');
});
4 changes: 2 additions & 2 deletions src/head-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { join } from 'node:path';
import { artifactManifest, parseManifest } from './deployment.ts';

export const HEAD_TURN_PATH = 'demos/head-turn';
// Approved ten-file preview inventory, including the confirmed depth revision.
export const HEAD_TURN_BUNDLE_SHA256 = '515c89f1307308e5e41dcfe5a0fc7c69e5751539863ef7cb7c14e1cf6ffa38e2';
// Approved ten-file preview inventory, including the confirmed depth and GPU displacement revisions.
export const HEAD_TURN_BUNDLE_SHA256 = 'fb7bc15b188ed8f29a931067df44a4c3cdb61ba3f114c706722eee73c8a6c2fc';
export function verifyHeadTurnBundle(root: string): void {
const demo = join(root, HEAD_TURN_PATH);
const expected = parseManifest(readFileSync(join(demo, 'bundle-manifest.json'), 'utf8'));
Expand Down
Loading