Skip to content

Commit 63f6e79

Browse files
obiotclaude
andcommitted
test(camera3d): expanded isVisible coverage + PR #1464 regression
Adds 15 new tests on top of the 5 that landed with the initial frustum-culling commit. Targets the parts of the visibility surface that were uncovered: Vertical FOV / pitch: - sprite far above the camera (outside vertical FOV) → culled - sprite far below the camera → culled - pitching up brings an above-frustum sprite into view - pitching down brings a below-frustum sprite into view Sphere edge cases: - sphere center behind near plane but radius pokes through → visible - sprite at exactly the far plane → visible (edge of frustum) - 1×1 px sprite deep in the frustum → still correctly classified Off-axis camera positions: - camera shifted right by 1000 units: sprite at the camera's new forward axis is visible, sprite at world origin is now outside FOV Narrow FOV: - wide-FOV cam sees a sprite that narrow-FOV cam culls (same world pose) — proves fov actually affects culling, not just rendering User-reported regression (PR #1464): - reproduces the exact scenario the user reported: Camera3d example with 3 monsters at z=200/400/600, camera at (0, 0, -300) orbiting z=400 at distance=700, one left-arrow click (yaw -= 0.15). All 3 monsters MUST remain visible after the click. Pre-fix, inheriting Camera2d's worldView rect silently culled all three. Stress variant: 8 clicks (yaw ≈ -69°) — orbit-target monster still in frame. Multi-update consistency: - moving camera far without calling update keeps the old planes (verifies no auto-rebuild) - update rebuilds the planes, isVisible reflects the new pose - moving back + update again gets the original behavior — proves planes are state-correct across multiple updates 3631 tests pass (was 3620 + 11 new isVisible tests + cleanup of the debug logging in camera3d.ts I added while diagnosing the user report). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bbfcfce commit 63f6e79

1 file changed

Lines changed: 184 additions & 0 deletions

File tree

packages/melonjs/tests/camera3d.spec.js

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,190 @@ describe("Camera3d", () => {
304304
outsideViewport.floating = true;
305305
expect(cam.isVisible(outsideViewport)).toBe(false);
306306
});
307+
308+
// ---- vertical FOV / pitch ----
309+
310+
it("sprite far above the camera (outside vertical FOV) is culled", () => {
311+
const cam = setupCam();
312+
// Y-down: large negative y is "above" (off the top of the screen)
313+
const sprite = new Renderable(0, -5000, 32, 32);
314+
sprite.pos.z = 100;
315+
expect(cam.isVisible(sprite)).toBe(false);
316+
});
317+
318+
it("sprite far below the camera (outside vertical FOV) is culled", () => {
319+
const cam = setupCam();
320+
const sprite = new Renderable(0, 5000, 32, 32);
321+
sprite.pos.z = 100;
322+
expect(cam.isVisible(sprite)).toBe(false);
323+
});
324+
325+
it("pitching up reveals a sprite that's above the original frustum", () => {
326+
const cam = setupCam();
327+
// sprite well above the camera in Y-down coords
328+
const sprite = new Renderable(0, -800, 32, 32);
329+
sprite.pos.z = 200;
330+
expect(cam.isVisible(sprite)).toBe(false);
331+
332+
// pitch up — frustum tilts to include things above
333+
cam.pitch = Math.PI / 3; // 60° upward
334+
cam.update();
335+
expect(cam.isVisible(sprite)).toBe(true);
336+
});
337+
338+
it("pitching down reveals a sprite that's below the original frustum", () => {
339+
const cam = setupCam();
340+
const sprite = new Renderable(0, 800, 32, 32);
341+
sprite.pos.z = 200;
342+
expect(cam.isVisible(sprite)).toBe(false);
343+
344+
cam.pitch = -Math.PI / 3; // 60° downward
345+
cam.update();
346+
expect(cam.isVisible(sprite)).toBe(true);
347+
});
348+
349+
// ---- sphere edge cases ----
350+
351+
it("sprite straddling the near plane (center behind, radius pokes through) is visible", () => {
352+
const cam = new Camera3d(0, 0, 800, 600, { near: 1, far: 1000 });
353+
cam.pos.set(0, 0, 0);
354+
cam.update();
355+
// sprite center at z=-0.5 (behind near plane at z=1) but radius
356+
// large enough that the sphere overlaps the near plane
357+
const sprite = new Renderable(0, 0, 200, 200);
358+
sprite.pos.z = -0.5;
359+
expect(cam.isVisible(sprite)).toBe(true);
360+
});
361+
362+
it("sprite at exactly the far plane is visible (edge of frustum)", () => {
363+
const cam = new Camera3d(0, 0, 800, 600, { near: 0.1, far: 1000 });
364+
cam.pos.set(0, 0, 0);
365+
cam.update();
366+
const sprite = new Renderable(0, 0, 32, 32);
367+
sprite.pos.z = 1000; // exactly at far
368+
expect(cam.isVisible(sprite)).toBe(true);
369+
});
370+
371+
it("very small sprite (1px) deep in the frustum is still classified correctly", () => {
372+
const cam = setupCam();
373+
const sprite = new Renderable(0, 0, 1, 1);
374+
sprite.pos.z = 500; // well inside frustum
375+
expect(cam.isVisible(sprite)).toBe(true);
376+
});
377+
378+
// ---- off-axis camera positions ----
379+
380+
it("works with camera offset in X (not just at origin)", () => {
381+
const cam = new Camera3d(0, 0, 800, 600);
382+
cam.pos.set(1000, 0, 0); // camera shifted right
383+
cam.update();
384+
// sprite at world (1000, 0, 200) is straight ahead of THIS camera
385+
const sprite = new Renderable(1000, 0, 32, 32);
386+
sprite.pos.z = 200;
387+
expect(cam.isVisible(sprite)).toBe(true);
388+
// sprite at world (0, 0, 200) is 1000 units to the left of camera —
389+
// outside the horizontal FOV
390+
const sprite2 = new Renderable(0, 0, 32, 32);
391+
sprite2.pos.z = 200;
392+
expect(cam.isVisible(sprite2)).toBe(false);
393+
});
394+
395+
// ---- narrow FOV ----
396+
397+
it("narrow FOV culls sprites that wide FOV would include", () => {
398+
const wideCam = new Camera3d(0, 0, 800, 600, { fov: Math.PI / 2 });
399+
wideCam.pos.set(0, 0, -200);
400+
wideCam.update();
401+
const narrowCam = new Camera3d(0, 0, 800, 600, {
402+
fov: Math.PI / 12, // 15° — very narrow telephoto
403+
});
404+
narrowCam.pos.set(0, 0, -200);
405+
narrowCam.update();
406+
407+
// sprite off to the side: wide FOV should see it, narrow shouldn't
408+
const sprite = new Renderable(150, 0, 32, 32);
409+
sprite.pos.z = 100;
410+
expect(wideCam.isVisible(sprite)).toBe(true);
411+
expect(narrowCam.isVisible(sprite)).toBe(false);
412+
});
413+
414+
// ---- regression: PR #1464 user report ----
415+
416+
it("user-reported regression: sprites stay visible after a single left-arrow click", () => {
417+
// Reproduces the exact scenario from the user report on
418+
// PR #1464: Camera3d example with 3 monsters at z=200/400/600,
419+
// camera at (0, 0, -300) orbiting target z=400 at distance=700,
420+
// one left-arrow click (yaw -= 0.15). Pre-frustum-culling fix,
421+
// inheriting Camera2d's worldView 2D-rect test silently culled
422+
// the monsters because the rect was at the camera's pos.x/y,
423+
// not in the actual perspective view.
424+
const cam = new Camera3d(0, 0, 1024, 768, {
425+
fov: Math.PI / 3,
426+
near: 0.1,
427+
far: 1000,
428+
});
429+
430+
const sprites = [200, 400, 600].map((z) => {
431+
const s = new Renderable(0, 0, 112, 112); // monster size after 0.5 scale
432+
s.pos.z = z;
433+
return s;
434+
});
435+
436+
// initial camera pose: yaw=0 pitch=0 distance=700 orbiting z=400
437+
const orbit = (yaw, pitch, distance, target) => {
438+
cam.pos.set(
439+
Math.sin(yaw) * Math.cos(pitch) * -distance,
440+
Math.sin(pitch) * distance,
441+
target - Math.cos(yaw) * Math.cos(pitch) * distance,
442+
);
443+
cam.lookAt(0, 0, target);
444+
cam.update();
445+
};
446+
447+
orbit(0, 0, 700, 400);
448+
// at initial pose, all 3 monsters in front of camera → visible
449+
for (const s of sprites) {
450+
expect(cam.isVisible(s)).toBe(true);
451+
}
452+
453+
// simulate one left-arrow click — yaw decreases by 0.15
454+
orbit(-0.15, 0, 700, 400);
455+
// regression: every monster must STILL be visible after the
456+
// camera orbits slightly. Pre-fix, all 3 silently disappeared.
457+
for (const s of sprites) {
458+
expect(cam.isVisible(s)).toBe(true);
459+
}
460+
461+
// stress: 8 clicks to the left (yaw = -1.2 ≈ 69°) — camera
462+
// orbits to the side; front monster might rotate out of view
463+
// but the middle (target) one should remain inside
464+
orbit(-1.2, 0, 700, 400);
465+
expect(cam.isVisible(sprites[1])).toBe(true); // middle, orbited around
466+
});
467+
468+
// ---- multi-update consistency ----
469+
470+
it("planes update correctly on every update() call (no stale state)", () => {
471+
const cam = setupCam();
472+
const sprite = new Renderable(0, 0, 32, 32);
473+
sprite.pos.z = 200;
474+
expect(cam.isVisible(sprite)).toBe(true);
475+
476+
// move camera way off, no update yet — isVisible still sees
477+
// the old planes
478+
cam.pos.set(10000, 10000, 10000);
479+
// (no update call — verifies planes don't auto-rebuild)
480+
expect(cam.isVisible(sprite)).toBe(true);
481+
482+
// after update, planes refresh and reflect the new pose
483+
cam.update();
484+
expect(cam.isVisible(sprite)).toBe(false);
485+
486+
// move back, update again — planes refresh
487+
cam.pos.set(0, 0, -200);
488+
cam.update();
489+
expect(cam.isVisible(sprite)).toBe(true);
490+
});
307491
});
308492

309493
describe("backward compat with Camera2d API", () => {

0 commit comments

Comments
 (0)