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
15 changes: 10 additions & 5 deletions packages/sdk-browser/src/camera/controls/firstPerson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,24 @@ test('first person turns the head with the pointer and lets the lock go on dispo
assert.equal(surface.listeners(), 0);
});

test('the first move after the lock is granted is dropped: the cursor jump never turns the head', () => {
test('the first move after the lock is granted is dropped: the cursor jump never turns the head, and a turn and its reverse return the head', () => {
const { camera, surface, controls } = steered(createFirstPersonCameraControls);
const looking = () => [...facing(camera)].map((v) => round(v));
controls.lookSpeed = Math.PI / 400;
controls.update(0);
const home = looking();
surface.fire('pointerdown', { pointerId: 1, button: 0, clientX: 0, clientY: 0 });
surface.key('pointerlockchange', {});
surface.fire('pointermove', { pointerId: 1, movementX: -900, movementY: 700 });
surface.fire('pointermove', { pointerId: 1, movementX: 200, movementY: 0 });
controls.update(0);
assert.deepEqual(
[...facing(camera)].map((v) => round(v)),
[1, 0, 0],
);
assert.deepEqual(looking(), [1, 0, 0]);
// The examples proof's look (#527): every move after the dropped one counts, so the reverse,
// in four steps, lands on the very head the lock started from.
for (let step = 0; step < 4; step++)
surface.fire('pointermove', { pointerId: 1, movementX: -50, movementY: 0 });
controls.update(0);
assert.deepEqual(looking(), home);
});

test('first person stops a downward look at `minPitch`', () => {
Expand Down
9 changes: 5 additions & 4 deletions scripts/docs-examples-draw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ test('a sparse example is declared by name and backend under the tenth; every ot
assert.ok(share > 0 && share < 0.1, id);
}
// The shares declared under those measured on 2026-09-24, and the examples never declared: the
// three a refused WebGL2 session left blank, and save-the-scene, whose ground fills its frame
// (#717). They keep the tenth on both backends.
// three a refused WebGL2 session left blank, save-the-scene, whose ground fills its frame
// (#717), and click-to-pick, framed closer (#527). They keep the tenth on both backends.
const named = [
'a-staircase-from-one-step',
'a-cloud-of-points',
Expand All @@ -58,13 +58,14 @@ test('a sparse example is declared by name and backend under the tenth; every ot
'fly-over-a-model-town',
'from-a-grain-to-a-planet',
'snow-of-sprites',
'click-to-pick',
];
for (const id of named) assert.ok(ids.has(id), id);
// A new declaration is asserted here too, with its literal share.
for (const id of Object.keys(SPARSE)) assert.ok(named.includes(id), id);
const least = (gpu: boolean) => named.map((id) => leastDrawn(id, gpu));
assert.deepEqual(least(false), [0.04, 0.04, 0.1, 0.1, 0.1, 0.1, 0.1]);
assert.deepEqual(least(true), [0.04, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]);
assert.deepEqual(least(false), [0.04, 0.04, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]);
assert.deepEqual(least(true), [0.04, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]);
});

test('the proof expects Jolt from the pages whose own source turns physics on, and from no other', async () => {
Expand Down
3 changes: 2 additions & 1 deletion site/examples/click-to-pick.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
mesh.name = names[i];
world.scene.add(mesh);
});
world.camera.position.set(0, 4, 6);
// Close enough that the ring fills the view: six shapes on a wide grid are a small target.
world.camera.position.set(0, 3.2, 4.8);
world.camera.lookAt(0, 0, 0);

controls({});
Expand Down
6 changes: 5 additions & 1 deletion tests/browser/renders/default-backend.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,12 @@ try {
JSON.stringify(withoutGpu.cases.default.metrics),
);
// The canvas is not empty: a twentieth of it at least differs from the cleared background.
// Each machine named in its own verdict, with the frame's counters: a blank side says which (#527).
for (const run of [withGpu, withoutGpu])
assert.ok(run.drawn && run.drawn.drawn > run.drawn.totalPixels / 20, JSON.stringify(run.drawn));
assert.ok(
run.drawn && run.drawn.drawn > run.drawn.totalPixels / 20,
`${machineLabel(run.cases.default.webgpu)}: ${JSON.stringify({ drawn: run.drawn, metrics: run.cases.default.metrics })}`,
);
assert.deepEqual(errors, []);
const side = (name: string, run: MachineResult) => [
name,
Expand Down
13 changes: 9 additions & 4 deletions tests/browser/renders/native-camera-controls.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
endGesture,
loadedModules,
openProbe,
pointerLocked,
stepGesture,
} from '../support/cameraControlsPage.ts';
import type { Page } from 'playwright';
Expand Down Expand Up @@ -76,9 +77,9 @@ try {
assert.ok(result.changes > 0, `${kind} emitted no change`);
assert.ok(result.bytes > 0, `${kind} drew nothing`);
assert.notDeepEqual(moved, home, `${kind} did not move the camera`);
// The gesture and its reverse leave the very image the pose started from.
assert.equal(result.differences, 0, `${kind} did not come back to its pose`);
// The gesture and its reverse leave the pose it started from, then the very image.
assert.deepEqual(result.pose, home, `${kind} did not come back to its pose`);
assert.equal(result.differences, 0, `${kind} did not come back to its image`);
};
await run('controls', async () => {
await drag(page, 120, 0);
Expand Down Expand Up @@ -118,12 +119,16 @@ try {
});
// A locked pointer turns the head on every move, button or not, so the look here is one
// press, out and back, rather than two drags that would each reposition the cursor first.
// Once the lock is granted the head drops the first move by design, the cursor's jump (#385):
// one move is spent on it, then four out and four back all count.
await run('firstPersonControls', async () => {
await page.mouse.move(centre.x, centre.y);
await page.mouse.down();
await page.mouse.move(centre.x + 80, centre.y, { steps: 4 });
await page.evaluate(pointerLocked);
await page.mouse.move(centre.x + 1, centre.y);
await page.mouse.move(centre.x + 81, centre.y, { steps: 4 });
const moved = await page.evaluate(stepGesture, 0);
await page.mouse.move(centre.x, centre.y, { steps: 4 });
await page.mouse.move(centre.x + 1, centre.y, { steps: 4 });
await page.mouse.up();
await page.evaluate(stepGesture, 0);
await hold(page, 'w');
Expand Down
18 changes: 18 additions & 0 deletions tests/browser/support/cameraControlsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,24 @@ export async function endGesture() {
return { differences, pose: probe.pose(), changes: probe.changes, bytes: image.length };
}

/** Resolves once the pointer lock a press asked for is granted, at once if it already is; a
* refused lock rejects. Waited for as the event, never by a fixed delay. */
export function pointerLocked() {
return new Promise<void>((granted, refused) => {
if (document.pointerLockElement) return granted();
// The first of the two events settles the wait and removes both listeners; a change that
// leaves the pointer free is a lost lock, never a granted one.
const settled = new AbortController();
const settle = () => {
settled.abort();
if (document.pointerLockElement) granted();
else refused(new Error('lock refused or released'));
};
document.addEventListener('pointerlockchange', settle, { signal: settled.signal });
document.addEventListener('pointerlockerror', settle, { signal: settled.signal });
});
}

/** Every module the page fetched, for the proof that no addon of the host is among them. */
export function loadedModules() {
return performance.getEntriesByType('resource').map((entry) => entry.name);
Expand Down
Loading