Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a02e2d9
feat: add camera culling types and interfaces for 3D rendering
hun756 May 4, 2026
4b00ea3
feat: add camera culling error handling with multilingual support
hun756 May 4, 2026
8000826
feat: implement camera culling internal logic with validation and mat…
hun756 May 4, 2026
8aaee25
feat(geomatry): implement camera frustum class with plane classificat…
hun756 May 4, 2026
955e54a
feat: implement FrustumCuller class for efficient visibility culling …
hun756 May 4, 2026
6e73916
feat: implement Camera3D class with projection handling and frustum c…
hun756 May 4, 2026
e3fb0c8
feat: add unit tests for camera frustum culling and enhance module ex…
hun756 May 4, 2026
66b7b38
feat: enhance RenderFrameClassifier with camera frustum intersection …
hun756 May 4, 2026
ded1d10
feat: add culling camera and bounded primitive creation for frustum c…
hun756 May 4, 2026
270fa93
feat: integrate Camera3D for enhanced view and projection matrix hand…
hun756 May 4, 2026
c1de060
feat: enhance SceneCameraFrameStateCollector tests with additional ca…
hun756 May 4, 2026
2d625f6
feat: add benchmark for frustum culling performance
hun756 May 5, 2026
11bda4a
feat: enhance ParticleSystemRenderer with Camera3D and CameraFrustum …
hun756 May 5, 2026
7fe62f8
feat: optimize depth calculation in BatchRenderer using getTranslatio…
hun756 May 5, 2026
72fd0ea
feat: add tests for ParticleSystemRenderer with Camera3D frustum cull…
hun756 May 5, 2026
be72314
feat: add unit test for depth calculation in BatchRenderer
hun756 May 5, 2026
f857de7
feat(scene-runtime): add scene mesh bounds handling to SceneMeshDefin…
hun756 May 5, 2026
289ec80
feat(scene-runtime): integrate scene mesh bounds resolution and enhan…
hun756 May 5, 2026
5854581
feat(scene-runtime): add test for culling mesh renderers outside came…
hun756 May 5, 2026
3f05b56
feat(tests): add bounds validation for mesh definitions
hun756 May 5, 2026
5e14c6e
feat(scene-runtime): enhance mesh definition adaptation with bounds h…
hun756 May 5, 2026
e9fa3cd
feat(tests): add test for converting glTF mesh bounds to scene boundi…
hun756 May 5, 2026
6599cb6
feat(scene-runtime): add camera frustum culling to sprite render item…
hun756 May 5, 2026
3a49933
feat(scene-runtime): pass camera frustum to sprite item collector dur…
hun756 May 5, 2026
64ec8a8
feat(tests): add unit test for culling sprites outside camera frustum…
hun756 May 5, 2026
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
129 changes: 129 additions & 0 deletions web/packages/geometry/bench/camera-culling.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { performance } from 'node:perf_hooks';
import { Camera3D, FrustumCuller } from '@axrone/geometry';

interface BenchItem {
readonly id: number;
readonly bounds: {
readonly kind: 'sphere';
readonly center: readonly [number, number, number];
readonly radius: number;
};
}

interface BenchResult {
readonly mode: 'sync' | 'async';
readonly durationMs: number;
readonly visibleCount: number;
readonly opsPerSecond: number;
}

const parseIntegerArg = (name: string, fallback: number): number => {
const prefix = `--${name}=`;
const raw = process.argv.find((value) => value.startsWith(prefix));
if (!raw) {
return fallback;
}

const parsed = Number.parseInt(raw.slice(prefix.length), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};

const itemCount = parseIntegerArg('items', 50000);
const iterations = parseIntegerArg('iterations', 8);
const asyncBatchSize = parseIntegerArg('batchSize', 1024);

const camera = Camera3D.perspective({
id: 'bench-camera',
projection: {
kind: 'perspective',
verticalFieldOfView: Math.PI / 3,
aspectRatio: 16 / 9,
near: 0.1,
far: 500,
},
pose: {
position: [0, 4, 16],
target: [0, 0, 0],
},
});

const createItems = (count: number): BenchItem[] => {
const items: BenchItem[] = new Array(count);
for (let index = 0; index < count; index += 1) {
const ring = index % 2048;
const layer = Math.floor(index / 2048);
const angle = ring * 0.017453292519943295;
const radius = 6 + (ring % 48) * 0.75;
const x = Math.cos(angle) * radius;
const y = ((layer % 9) - 4) * 1.5;
const z = -8 - layer * 0.9 - Math.sin(angle) * radius;
items[index] = {
id: index,
bounds: {
kind: 'sphere',
center: [x, y, z],
radius: 0.6 + (index % 5) * 0.1,
},
};
}
return items;
};

const items = createItems(itemCount);
const culler = new FrustumCuller<BenchItem>({
bounds: (item) => item.bounds,
asyncBatchSize,
});

const runSync = (): BenchResult => {
const startedAt = performance.now();
for (let iteration = 0; iteration < iterations; iteration += 1) {
culler.cull(items, camera.frustum);
}
const durationMs = performance.now() - startedAt;
return {
mode: 'sync',
durationMs,
visibleCount: culler.visible.length,
opsPerSecond: (itemCount * iterations * 1000) / durationMs,
};
};

const runAsync = async (): Promise<BenchResult> => {
const startedAt = performance.now();
for (let iteration = 0; iteration < iterations; iteration += 1) {
await culler.cullAsync(items, camera.frustum, {
batchSize: asyncBatchSize,
scheduler: async () => undefined,
});
}
const durationMs = performance.now() - startedAt;
return {
mode: 'async',
durationMs,
visibleCount: culler.visible.length,
opsPerSecond: (itemCount * iterations * 1000) / durationMs,
};
};

const printResult = (result: BenchResult): void => {
const label = result.mode.padEnd(5, ' ');
console.log(
`${label} duration=${result.durationMs.toFixed(2)}ms visible=${result.visibleCount} throughput=${result.opsPerSecond.toFixed(0)} items/sec`
);
};

const main = async (): Promise<void> => {
console.log(
`geometry-culling-benchmark items=${itemCount} iterations=${iterations} batchSize=${asyncBatchSize}`
);
const syncResult = runSync();
const asyncResult = await runAsync();
printResult(syncResult);
printResult(asyncResult);
};

void main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
1 change: 1 addition & 0 deletions web/packages/geometry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
}
},
"scripts": {
"bench:culling": "tsx --tsconfig ../../tsconfig.json ./bench/camera-culling.bench.ts",
"build": "rollup -c rollup.config.mjs",
"clean": "rimraf dist",
"test": "vitest run"
Expand Down
139 changes: 139 additions & 0 deletions web/packages/geometry/src/__tests__/camera-frustum-culling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';
import { Camera3D, CameraFrustum, FrustumCuller, createBoundingAabb, createBoundingSphere } from '@axrone/geometry';

describe('camera frustum culling', () => {
it('classifies perspective spheres against the view frustum', () => {
const camera = Camera3D.perspective({
projection: {
kind: 'perspective',
verticalFieldOfView: Math.PI / 3,
aspectRatio: 1,
near: 0.1,
far: 100,
},
pose: {
position: [0, 0, 0],
target: [0, 0, -1],
},
});

expect(camera.classify(createBoundingSphere([0, 0, -5], 1))).toBe('inside');
expect(camera.classify(createBoundingSphere([5, 0, -5], 0.25))).toBe('outside');
expect(camera.classify(createBoundingSphere([0, 0, -0.05], 0.2))).toBe('intersects');
});

it('classifies orthographic boxes against the view frustum', () => {
const camera = Camera3D.orthographic({
projection: {
kind: 'orthographic',
left: -2,
right: 2,
bottom: -2,
top: 2,
near: 0.1,
far: 20,
},
pose: {
position: [0, 0, 5],
target: [0, 0, 0],
},
});

expect(camera.classify(createBoundingAabb([-1, -1, -1], [1, 1, 1]))).toBe('inside');
expect(camera.classify(createBoundingAabb([3, -1, -1], [5, 1, 1]))).toBe('outside');
});

it('serializes and restores camera state without losing classification behavior', () => {
const camera = Camera3D.perspective({
id: 'main-camera',
projection: {
kind: 'perspective',
verticalFieldOfView: Math.PI / 2,
aspectRatio: 16 / 9,
near: 0.5,
far: 250,
},
pose: {
position: [2, 3, 10],
target: [2, 3, 0],
up: [0, 1, 0],
},
});

const serialized = camera.toJSON();
const restored = Camera3D.fromJSON(serialized);

expect(restored.toJSON()).toEqual(serialized);
expect(restored.intersects(createBoundingSphere([2, 3, 1], 1))).toBe(true);
expect(restored.intersects(createBoundingSphere([50, 3, 1], 1))).toBe(false);
});

it('reuses culling buffers and tracks overflow without throwing by default', () => {
const camera = Camera3D.perspective({
projection: {
kind: 'perspective',
verticalFieldOfView: Math.PI / 3,
aspectRatio: 1,
near: 0.1,
far: 100,
},
pose: {
position: [0, 0, 0],
target: [0, 0, -1],
},
});

const items = [
{ id: 'sphere:inside', bounds: createBoundingSphere([0, 0, -4], 0.5) },
{ id: 'sphere:outside', bounds: createBoundingSphere([10, 0, -4], 0.5) },
{ id: 'aabb:inside', bounds: createBoundingAabb([-0.5, -0.5, -3], [0.5, 0.5, -2]) },
] as const;

const culler = new FrustumCuller<typeof items[number]>({
bounds: (item) => item.bounds,
maxResults: 1,
trackClassifications: true,
});

culler.cull(items, camera.frustum);

expect(culler.visible).toHaveLength(1);
expect(culler.stats.visibleCount).toBe(1);
expect(culler.stats.overflowed).toBe(true);
expect(culler.stats.sphereCount).toBe(2);
expect(culler.stats.aabbCount).toBe(1);
expect(culler.classifications?.get(items[1])).toBe('outside');
});

it('supports async culling with batched yielding', async () => {
const frustum = new CameraFrustum(Camera3D.perspective({
projection: {
kind: 'perspective',
verticalFieldOfView: Math.PI / 3,
aspectRatio: 1,
near: 0.1,
far: 100,
},
pose: {
position: [0, 0, 0],
target: [0, 0, -1],
},
}).viewProjectionMatrix);

const items = [
createBoundingSphere([0, 0, -3], 0.5),
createBoundingSphere([0, 0, -30], 0.5),
createBoundingSphere([30, 0, -3], 0.5),
];
const culler = new FrustumCuller({
bounds: (item: (typeof items)[number]) => item,
trackClassifications: true,
});

await culler.cullAsync(items, frustum, { batchSize: 1 });

expect(culler.visible).toHaveLength(2);
expect(culler.stats.outsideCount).toBe(1);
expect(culler.classifications?.get(items[2])).toBe('outside');
});
});
Loading
Loading