From a02e2d9686cc1863eac98b536166e52a7c8d940a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Mon, 4 May 2026 20:30:23 +0300 Subject: [PATCH 01/25] feat: add camera culling types and interfaces for 3D rendering --- .../geometry/src/camera-culling-types.ts | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 web/packages/geometry/src/camera-culling-types.ts diff --git a/web/packages/geometry/src/camera-culling-types.ts b/web/packages/geometry/src/camera-culling-types.ts new file mode 100644 index 00000000..0125da9d --- /dev/null +++ b/web/packages/geometry/src/camera-culling-types.ts @@ -0,0 +1,159 @@ +import type { IMat4Like, IVec3Like } from '@axrone/numeric'; +import type { Brand, ReadonlyTuple3 } from '@axrone/utility'; + +export type CameraId = Brand; +export type CameraLocale = 'en' | 'tr' | (string & {}); + +export type Vector3Input = Readonly | ReadonlyTuple3; + +export type Matrix4Tuple = readonly [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, +]; + +export interface CameraPose { + readonly position: Vector3Input; + readonly target: Vector3Input; + readonly up?: Vector3Input; +} + +export interface ResolvedCameraPose { + readonly position: ReadonlyTuple3; + readonly target: ReadonlyTuple3; + readonly up: ReadonlyTuple3; +} + +export interface CameraPerspectiveProjection { + readonly kind: 'perspective'; + readonly verticalFieldOfView: number; + readonly aspectRatio: number; + readonly near: number; + readonly far: number; +} + +export interface CameraOrthographicProjection { + readonly kind: 'orthographic'; + readonly left: number; + readonly right: number; + readonly bottom: number; + readonly top: number; + readonly near: number; + readonly far: number; +} + +export type CameraProjection = CameraPerspectiveProjection | CameraOrthographicProjection; +export type CameraProjectionKind = CameraProjection['kind']; +export type CameraProjectionOf = Extract< + CameraProjection, + { readonly kind: TKind } +>; + +export interface CameraOptions { + readonly id?: string; + readonly locale?: CameraLocale; + readonly projection: TProjection; + readonly pose: CameraPose; +} + +export type CameraOptionsOf = CameraOptions< + CameraProjectionOf +>; + +export interface CameraSerialized { + readonly id: string; + readonly locale: CameraLocale; + readonly projection: TProjection; + readonly pose: ResolvedCameraPose; + readonly viewMatrix: Matrix4Tuple; + readonly projectionMatrix: Matrix4Tuple; + readonly viewProjectionMatrix: Matrix4Tuple; +} + +export interface BoundingSphere { + readonly kind: 'sphere'; + readonly center: Vector3Input; + readonly radius: number; +} + +export interface BoundingAabb { + readonly kind: 'aabb'; + readonly min: Vector3Input; + readonly max: Vector3Input; +} + +export type BoundingVolume = BoundingSphere | BoundingAabb; +export type BoundingVolumeKind = BoundingVolume['kind']; +export type BoundingVolumeOf = Extract< + BoundingVolume, + { readonly kind: TKind } +>; + +export type FrustumClassification = 'outside' | 'intersects' | 'inside'; +export type PointFrustumClassification = Extract; + +export interface BoundsResolver { + (item: TItem): TBounds | null | undefined; +} + +export interface CullingFilter { + (item: TItem): boolean; +} + +export interface CullingSorter { + (left: TItem, right: TItem): number; +} + +export type OverflowStrategy = 'trim' | 'throw'; + +export interface FrustumCullerOptions { + readonly locale?: CameraLocale; + readonly bounds: BoundsResolver; + readonly filter?: CullingFilter; + readonly sort?: CullingSorter; + readonly maxResults?: number; + readonly overflow?: OverflowStrategy; + readonly trackClassifications?: boolean; + readonly asyncBatchSize?: number; +} + +export interface FrustumCullerAsyncOptions { + readonly batchSize?: number; + readonly signal?: AbortSignal; + readonly scheduler?: () => void | PromiseLike; +} + +export type CullingMetricKey = + | `${BoundingVolumeKind}Count` + | `visible${Capitalize}Count`; + +export type CullingMetricRecord = { + readonly [K in BoundingVolumeKind as `${K}Count`]: number; +} & { + readonly [K in BoundingVolumeKind as `visible${Capitalize}Count`]: number; +}; + +export interface CullingStats extends CullingMetricRecord { + readonly totalCount: number; + readonly visibleCount: number; + readonly outsideCount: number; + readonly insideCount: number; + readonly intersectCount: number; + readonly skippedCount: number; + readonly overflowed: boolean; +} + +export type MatrixLike = Readonly; \ No newline at end of file From 4b00ea3e3a8bb870e653af86bb642e37323d194a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Mon, 4 May 2026 20:30:51 +0300 Subject: [PATCH 02/25] feat: add camera culling error handling with multilingual support --- .../geometry/src/camera-culling-errors.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 web/packages/geometry/src/camera-culling-errors.ts diff --git a/web/packages/geometry/src/camera-culling-errors.ts b/web/packages/geometry/src/camera-culling-errors.ts new file mode 100644 index 00000000..61e8572a --- /dev/null +++ b/web/packages/geometry/src/camera-culling-errors.ts @@ -0,0 +1,121 @@ +import type { CameraLocale } from './camera-culling-types'; + +export type CameraCullingErrorCode = + | 'CAMERA_DISPOSED' + | 'CULLER_DISPOSED' + | 'FRUSTUM_DISPOSED' + | 'INVALID_ARGUMENT' + | 'INVALID_BOUNDS' + | 'INVALID_CAMERA_ID' + | 'INVALID_MATRIX' + | 'INVALID_POSE' + | 'INVALID_PROJECTION' + | 'INVALID_RADIUS' + | 'INVALID_SERIALIZED_CAMERA' + | 'INVALID_VECTOR' + | 'OPERATION_ABORTED' + | 'RESULT_OVERFLOW'; + +export type CameraCullingErrorContext = Readonly>; + +const EN_MESSAGES: Record = { + CAMERA_DISPOSED: 'camera has already been disposed', + CULLER_DISPOSED: 'frustum culler has already been disposed', + FRUSTUM_DISPOSED: 'frustum has already been disposed', + INVALID_ARGUMENT: 'invalid argument supplied', + INVALID_BOUNDS: 'invalid bounding volume supplied', + INVALID_CAMERA_ID: 'camera id must be a non-empty string', + INVALID_MATRIX: 'matrix must contain 16 finite numeric elements', + INVALID_POSE: 'camera pose must describe a valid look direction and up vector', + INVALID_PROJECTION: 'projection settings are invalid', + INVALID_RADIUS: 'sphere radius must be a finite value greater than or equal to zero', + INVALID_SERIALIZED_CAMERA: 'serialized camera payload is invalid', + INVALID_VECTOR: 'vector must contain finite numeric components', + OPERATION_ABORTED: 'operation was aborted', + RESULT_OVERFLOW: 'visible result budget was exceeded', +}; + +const TR_MESSAGES: Record = { + CAMERA_DISPOSED: 'kamera zaten sonlandirildi', + CULLER_DISPOSED: 'gorunum hacmi ayiklayicisi zaten sonlandirildi', + FRUSTUM_DISPOSED: 'frustum zaten sonlandirildi', + INVALID_ARGUMENT: 'gecersiz bagimsiz degisken verildi', + INVALID_BOUNDS: 'gecersiz sinir hacmi verildi', + INVALID_CAMERA_ID: 'kamera kimligi bos olmayan bir metin olmali', + INVALID_MATRIX: 'matris 16 adet sonlu sayisal eleman icermeli', + INVALID_POSE: 'kamera pozu gecerli bir bakis yonu ve yukari vektoru tanimlamali', + INVALID_PROJECTION: 'projeksiyon ayarlari gecersiz', + INVALID_RADIUS: 'kure yaricapi sonlu ve sifirdan buyuk veya esit olmali', + INVALID_SERIALIZED_CAMERA: 'serilestirilmis kamera verisi gecersiz', + INVALID_VECTOR: 'vektor sonlu sayisal bilesenler icermeli', + OPERATION_ABORTED: 'islem iptal edildi', + RESULT_OVERFLOW: 'gorunur sonuc butcesi asildi', +}; + +const MESSAGE_TABLE: Readonly>> = { + en: EN_MESSAGES, + tr: TR_MESSAGES, +}; + +export const resolveCameraCullingMessage = ( + code: CameraCullingErrorCode, + locale: CameraLocale = 'en' +): string => { + if (locale === 'tr') { + return MESSAGE_TABLE.tr[code]; + } + return MESSAGE_TABLE.en[code]; +}; + +export class CameraCullingError extends Error { + readonly name = 'CameraCullingError'; + + constructor( + public readonly code: CameraCullingErrorCode, + public readonly locale: CameraLocale = 'en', + public readonly context: CameraCullingErrorContext = {}, + public override readonly cause?: unknown + ) { + super(resolveCameraCullingMessage(code, locale)); + } +} + +export class CameraValidationError extends CameraCullingError { + readonly name = 'CameraValidationError'; + + constructor( + code: Extract< + CameraCullingErrorCode, + | 'CAMERA_DISPOSED' + | 'CULLER_DISPOSED' + | 'FRUSTUM_DISPOSED' + | 'INVALID_ARGUMENT' + | 'INVALID_BOUNDS' + | 'INVALID_CAMERA_ID' + | 'INVALID_MATRIX' + | 'INVALID_POSE' + | 'INVALID_PROJECTION' + | 'INVALID_RADIUS' + | 'INVALID_VECTOR' + | 'OPERATION_ABORTED' + | 'RESULT_OVERFLOW' + >, + locale: CameraLocale = 'en', + context: CameraCullingErrorContext = {}, + cause?: unknown + ) { + super(code, locale, context, cause); + } +} + +export class CameraSerializationError extends CameraCullingError { + readonly name = 'CameraSerializationError'; + + constructor( + locale: CameraLocale = 'en', + context: CameraCullingErrorContext = {}, + cause?: unknown + ) { + super('INVALID_SERIALIZED_CAMERA', locale, context, cause); + } +} \ No newline at end of file From 80008268b739c376e90248524b8dee41c06eb39c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Mon, 4 May 2026 20:31:01 +0300 Subject: [PATCH 03/25] feat: implement camera culling internal logic with validation and matrix operations --- .../geometry/src/camera-culling-internal.ts | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 web/packages/geometry/src/camera-culling-internal.ts diff --git a/web/packages/geometry/src/camera-culling-internal.ts b/web/packages/geometry/src/camera-culling-internal.ts new file mode 100644 index 00000000..307c1140 --- /dev/null +++ b/web/packages/geometry/src/camera-culling-internal.ts @@ -0,0 +1,366 @@ +import { EPSILON, Mat4, type IMat4Like, type IVec3Like } from '@axrone/numeric'; +import type { ReadonlyTuple3 } from '@axrone/utility'; +import { CameraSerializationError, CameraValidationError } from './camera-culling-errors'; +import type { + BoundingAabb, + BoundingSphere, + BoundingVolume, + CameraId, + CameraLocale, + CameraProjection, + CameraSerialized, + Matrix4Tuple, + ResolvedCameraPose, + Vector3Input, +} from './camera-culling-types'; + +export const DEFAULT_CAMERA_LOCALE: CameraLocale = 'en'; +export const DEFAULT_UP_VECTOR: ReadonlyTuple3 = [0, 1, 0] as const; +export const DEFAULT_CAMERA_ID = 'camera'; + +export const readX = (value: Vector3Input): number => (Array.isArray(value) ? value[0] : value.x); +export const readY = (value: Vector3Input): number => (Array.isArray(value) ? value[1] : value.y); +export const readZ = (value: Vector3Input): number => (Array.isArray(value) ? value[2] : value.z); + +export const assertFiniteNumber = ( + value: unknown, + locale: CameraLocale, + field: string, + code: 'INVALID_ARGUMENT' | 'INVALID_PROJECTION' | 'INVALID_RADIUS' | 'INVALID_VECTOR' +): asserts value is number => { + if (typeof value !== 'number' || Number.isFinite(value) === false) { + throw new CameraValidationError(code, locale, { field, value }); + } +}; + +export const assertPositiveFiniteNumber = ( + value: unknown, + locale: CameraLocale, + field: string, + code: 'INVALID_ARGUMENT' | 'INVALID_PROJECTION' +): asserts value is number => { + assertFiniteNumber(value, locale, field, code); + if (value <= 0) { + throw new CameraValidationError(code, locale, { field, value }); + } +}; + +export const assertVector3 = ( + value: unknown, + locale: CameraLocale, + field: string +): asserts value is Vector3Input => { + if (Array.isArray(value)) { + if (value.length !== 3) { + throw new CameraValidationError('INVALID_VECTOR', locale, { field, value }); + } + assertFiniteNumber(value[0], locale, `${field}.x`, 'INVALID_VECTOR'); + assertFiniteNumber(value[1], locale, `${field}.y`, 'INVALID_VECTOR'); + assertFiniteNumber(value[2], locale, `${field}.z`, 'INVALID_VECTOR'); + return; + } + + if (typeof value !== 'object' || value === null) { + throw new CameraValidationError('INVALID_VECTOR', locale, { field, value }); + } + + const candidate = value as Partial; + assertFiniteNumber(candidate.x, locale, `${field}.x`, 'INVALID_VECTOR'); + assertFiniteNumber(candidate.y, locale, `${field}.y`, 'INVALID_VECTOR'); + assertFiniteNumber(candidate.z, locale, `${field}.z`, 'INVALID_VECTOR'); +}; + +export const assertMatrix = ( + matrix: unknown, + locale: CameraLocale, + field: string +): asserts matrix is Readonly => { + if (typeof matrix !== 'object' || matrix === null || !('data' in matrix)) { + throw new CameraValidationError('INVALID_MATRIX', locale, { field, matrix }); + } + + const data = (matrix as IMat4Like).data; + if (data.length < 16) { + throw new CameraValidationError('INVALID_MATRIX', locale, { field, matrix }); + } + + for (let index = 0; index < 16; index++) { + assertFiniteNumber(data[index], locale, `${field}[${index}]`, 'INVALID_ARGUMENT'); + } +}; + +export const createCameraId = (value: string | undefined, locale: CameraLocale): CameraId => { + const id = value?.trim() || DEFAULT_CAMERA_ID; + if (id.length === 0) { + throw new CameraValidationError('INVALID_CAMERA_ID', locale, { value }); + } + return id as CameraId; +}; + +export const cloneProjection = ( + projection: Readonly, + locale: CameraLocale +): TProjection => { + if (projection.kind === 'perspective') { + assertPositiveFiniteNumber( + projection.verticalFieldOfView, + locale, + 'projection.verticalFieldOfView', + 'INVALID_PROJECTION' + ); + if (projection.verticalFieldOfView >= Math.PI - EPSILON) { + throw new CameraValidationError('INVALID_PROJECTION', locale, { + field: 'projection.verticalFieldOfView', + value: projection.verticalFieldOfView, + }); + } + assertPositiveFiniteNumber( + projection.aspectRatio, + locale, + 'projection.aspectRatio', + 'INVALID_PROJECTION' + ); + assertPositiveFiniteNumber(projection.near, locale, 'projection.near', 'INVALID_PROJECTION'); + assertPositiveFiniteNumber(projection.far, locale, 'projection.far', 'INVALID_PROJECTION'); + if (projection.far <= projection.near + EPSILON) { + throw new CameraValidationError('INVALID_PROJECTION', locale, { projection }); + } + return Object.freeze({ + kind: 'perspective', + verticalFieldOfView: projection.verticalFieldOfView, + aspectRatio: projection.aspectRatio, + near: projection.near, + far: projection.far, + }) as TProjection; + } + + assertFiniteNumber(projection.left, locale, 'projection.left', 'INVALID_PROJECTION'); + assertFiniteNumber(projection.right, locale, 'projection.right', 'INVALID_PROJECTION'); + assertFiniteNumber(projection.bottom, locale, 'projection.bottom', 'INVALID_PROJECTION'); + assertFiniteNumber(projection.top, locale, 'projection.top', 'INVALID_PROJECTION'); + assertPositiveFiniteNumber(projection.near, locale, 'projection.near', 'INVALID_PROJECTION'); + assertPositiveFiniteNumber(projection.far, locale, 'projection.far', 'INVALID_PROJECTION'); + if ( + Math.abs(projection.left - projection.right) <= EPSILON || + Math.abs(projection.bottom - projection.top) <= EPSILON || + projection.far <= projection.near + EPSILON + ) { + throw new CameraValidationError('INVALID_PROJECTION', locale, { projection }); + } + + return Object.freeze({ + kind: 'orthographic', + left: projection.left, + right: projection.right, + bottom: projection.bottom, + top: projection.top, + near: projection.near, + far: projection.far, + }) as TProjection; +}; + +export const buildProjectionMatrix = ( + projection: Readonly, + out: Mat4, + locale: CameraLocale +): Mat4 => { + try { + if (projection.kind === 'perspective') { + return Mat4.perspective( + projection.verticalFieldOfView, + projection.aspectRatio, + projection.near, + projection.far, + out + ); + } + + return Mat4.orthographic( + projection.left, + projection.right, + projection.bottom, + projection.top, + projection.near, + projection.far, + out + ); + } catch (error) { + throw new CameraValidationError('INVALID_PROJECTION', locale, { projection }, error); + } +}; + +export const buildViewMatrix = ( + position: Vector3Input, + target: Vector3Input, + up: Vector3Input, + out: Mat4, + locale: CameraLocale +): Mat4 => { + assertVector3(position, locale, 'pose.position'); + assertVector3(target, locale, 'pose.target'); + assertVector3(up, locale, 'pose.up'); + + const eyeX = readX(position); + const eyeY = readY(position); + const eyeZ = readZ(position); + const targetX = readX(target); + const targetY = readY(target); + const targetZ = readZ(target); + const upX = readX(up); + const upY = readY(up); + const upZ = readZ(up); + + let z0 = eyeX - targetX; + let z1 = eyeY - targetY; + let z2 = eyeZ - targetZ; + let length = Math.hypot(z0, z1, z2); + + if (length <= EPSILON) { + throw new CameraValidationError('INVALID_POSE', locale, { position, target, up }); + } + + z0 /= length; + z1 /= length; + z2 /= length; + + let x0 = upY * z2 - upZ * z1; + let x1 = upZ * z0 - upX * z2; + let x2 = upX * z1 - upY * z0; + length = Math.hypot(x0, x1, x2); + + if (length <= EPSILON) { + throw new CameraValidationError('INVALID_POSE', locale, { position, target, up }); + } + + x0 /= length; + x1 /= length; + x2 /= length; + + const y0 = z1 * x2 - z2 * x1; + const y1 = z2 * x0 - z0 * x2; + const y2 = z0 * x1 - z1 * x0; + + const data = out.data as number[]; + data[0] = x0; + data[1] = x1; + data[2] = x2; + data[3] = -(x0 * eyeX + x1 * eyeY + x2 * eyeZ); + data[4] = y0; + data[5] = y1; + data[6] = y2; + data[7] = -(y0 * eyeX + y1 * eyeY + y2 * eyeZ); + data[8] = z0; + data[9] = z1; + data[10] = z2; + data[11] = -(z0 * eyeX + z1 * eyeY + z2 * eyeZ); + data[12] = 0; + data[13] = 0; + data[14] = 0; + data[15] = 1; + return out; +}; + +export const copyVector3 = (source: Vector3Input, target: IVec3Like): void => { + target.x = readX(source); + target.y = readY(source); + target.z = readZ(source); +}; + +export const toVector3Tuple = (value: Vector3Input): ReadonlyTuple3 => + [readX(value), readY(value), readZ(value)] as const; + +export const toMatrix4Tuple = (matrix: Readonly): Matrix4Tuple => { + const data = matrix.data; + return [ + data[0]!, + data[1]!, + data[2]!, + data[3]!, + data[4]!, + data[5]!, + data[6]!, + data[7]!, + data[8]!, + data[9]!, + data[10]!, + data[11]!, + data[12]!, + data[13]!, + data[14]!, + data[15]!, + ] as const; +}; + +export const assertBoundingSphere = ( + sphere: Readonly, + locale: CameraLocale +): void => { + assertVector3(sphere.center, locale, 'bounds.center'); + assertFiniteNumber(sphere.radius, locale, 'bounds.radius', 'INVALID_RADIUS'); + if (sphere.radius < 0) { + throw new CameraValidationError('INVALID_RADIUS', locale, { sphere }); + } +}; + +export const normalizedAabbExtents = ( + aabb: Readonly, + locale: CameraLocale +): { + readonly minX: number; + readonly minY: number; + readonly minZ: number; + readonly maxX: number; + readonly maxY: number; + readonly maxZ: number; +} => { + assertVector3(aabb.min, locale, 'bounds.min'); + assertVector3(aabb.max, locale, 'bounds.max'); + + const minX = Math.min(readX(aabb.min), readX(aabb.max)); + const minY = Math.min(readY(aabb.min), readY(aabb.max)); + const minZ = Math.min(readZ(aabb.min), readZ(aabb.max)); + const maxX = Math.max(readX(aabb.min), readX(aabb.max)); + const maxY = Math.max(readY(aabb.min), readY(aabb.max)); + const maxZ = Math.max(readZ(aabb.min), readZ(aabb.max)); + + return { minX, minY, minZ, maxX, maxY, maxZ }; +}; + +export const assertBoundingVolume = ( + bounds: Readonly, + locale: CameraLocale +): void => { + if (bounds.kind === 'sphere') { + assertBoundingSphere(bounds, locale); + return; + } + + normalizedAabbExtents(bounds, locale); +}; + +export const resolveSerializedCamera = ( + value: unknown, + locale: CameraLocale +): CameraSerialized => { + if (typeof value !== 'object' || value === null) { + throw new CameraSerializationError(locale, { value }); + } + + const camera = value as Partial>; + if (typeof camera.id !== 'string' || camera.projection === undefined || camera.pose === undefined) { + throw new CameraSerializationError(locale, { value }); + } + + return camera as CameraSerialized; +}; + +export const toResolvedPose = ( + position: Vector3Input, + target: Vector3Input, + up: Vector3Input +): ResolvedCameraPose => + Object.freeze({ + position: toVector3Tuple(position), + target: toVector3Tuple(target), + up: toVector3Tuple(up), + }); \ No newline at end of file From 8aaee251b75d8750842fe505331b6b6676a22a7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Mon, 4 May 2026 20:31:53 +0300 Subject: [PATCH 04/25] feat(geomatry): implement camera frustum class with plane classification and bounding volume checks --- web/packages/geometry/src/frustum.ts | 314 +++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 web/packages/geometry/src/frustum.ts diff --git a/web/packages/geometry/src/frustum.ts b/web/packages/geometry/src/frustum.ts new file mode 100644 index 00000000..0e9c95a4 --- /dev/null +++ b/web/packages/geometry/src/frustum.ts @@ -0,0 +1,314 @@ +import { EPSILON, type IMat4Like } from '@axrone/numeric'; +import type { IDisposable, ReadonlyTuple4 } from '@axrone/utility'; +import { CameraValidationError } from './camera-culling-errors'; +import { + assertBoundingSphere, + assertBoundingVolume, + assertMatrix, + normalizedAabbExtents, + readX, + readY, + readZ, + DEFAULT_CAMERA_LOCALE, +} from './camera-culling-internal'; +import type { + BoundingAabb, + BoundingSphere, + BoundingVolume, + CameraLocale, + FrustumClassification, + PointFrustumClassification, + Vector3Input, +} from './camera-culling-types'; + +const FRUSTUM_PLANE_COMPONENTS = 4; +const FRUSTUM_PLANE_COUNT = 6; +const LEFT_PLANE = 0; +const RIGHT_PLANE = 4; +const BOTTOM_PLANE = 8; +const TOP_PLANE = 12; +const NEAR_PLANE = 16; +const FAR_PLANE = 20; + +export type FrustumPlaneName = 'left' | 'right' | 'bottom' | 'top' | 'near' | 'far'; + +const normalizePlane = (planes: Float32Array, offset: number, locale: CameraLocale): void => { + const x = planes[offset]!; + const y = planes[offset + 1]!; + const z = planes[offset + 2]!; + const length = Math.hypot(x, y, z); + if (length <= EPSILON) { + throw new CameraValidationError('INVALID_MATRIX', locale, { offset, planes: Array.from(planes) }); + } + + planes[offset] = x / length; + planes[offset + 1] = y / length; + planes[offset + 2] = z / length; + planes[offset + 3] = planes[offset + 3]! / length; +}; + +const classifyPlaneSetPoint = ( + planes: Float32Array, + point: Vector3Input +): PointFrustumClassification => { + const x = readX(point); + const y = readY(point); + const z = readZ(point); + + for (let offset = 0; offset < planes.length; offset += FRUSTUM_PLANE_COMPONENTS) { + const distance = + planes[offset]! * x + + planes[offset + 1]! * y + + planes[offset + 2]! * z + + planes[offset + 3]!; + if (distance < 0) { + return 'outside'; + } + } + + return 'inside'; +}; + +const classifyPlaneSetSphere = ( + planes: Float32Array, + sphere: Readonly +): FrustumClassification => { + const x = readX(sphere.center); + const y = readY(sphere.center); + const z = readZ(sphere.center); + const radius = sphere.radius; + let classification: FrustumClassification = 'inside'; + + for (let offset = 0; offset < planes.length; offset += FRUSTUM_PLANE_COMPONENTS) { + const distance = + planes[offset]! * x + + planes[offset + 1]! * y + + planes[offset + 2]! * z + + planes[offset + 3]!; + if (distance < -radius) { + return 'outside'; + } + if (distance < radius) { + classification = 'intersects'; + } + } + + return classification; +}; + +const classifyPlaneSetAabb = ( + planes: Float32Array, + aabb: Readonly, + locale: CameraLocale +): FrustumClassification => { + const { minX, minY, minZ, maxX, maxY, maxZ } = normalizedAabbExtents(aabb, locale); + const centerX = (minX + maxX) * 0.5; + const centerY = (minY + maxY) * 0.5; + const centerZ = (minZ + maxZ) * 0.5; + const extentX = (maxX - minX) * 0.5; + const extentY = (maxY - minY) * 0.5; + const extentZ = (maxZ - minZ) * 0.5; + let classification: FrustumClassification = 'inside'; + + for (let offset = 0; offset < planes.length; offset += FRUSTUM_PLANE_COMPONENTS) { + const normalX = planes[offset]!; + const normalY = planes[offset + 1]!; + const normalZ = planes[offset + 2]!; + const distance = + normalX * centerX + normalY * centerY + normalZ * centerZ + planes[offset + 3]!; + const radius = + Math.abs(normalX) * extentX + + Math.abs(normalY) * extentY + + Math.abs(normalZ) * extentZ; + + if (distance < -radius) { + return 'outside'; + } + if (distance < radius) { + classification = 'intersects'; + } + } + + return classification; +}; + +export const isBoundingSphere = (value: unknown): value is BoundingSphere => + typeof value === 'object' && value !== null && (value as { kind?: unknown }).kind === 'sphere'; + +export const isBoundingAabb = (value: unknown): value is BoundingAabb => + typeof value === 'object' && value !== null && (value as { kind?: unknown }).kind === 'aabb'; + +export const isBoundingVolume = (value: unknown): value is BoundingVolume => + isBoundingSphere(value) || isBoundingAabb(value); + +export const createBoundingSphere = ( + center: Vector3Input, + radius: number, + locale: CameraLocale = DEFAULT_CAMERA_LOCALE +): BoundingSphere => { + const sphere = Object.freeze({ kind: 'sphere', center, radius }) satisfies BoundingSphere; + assertBoundingSphere(sphere, locale); + return sphere; +}; + +export const createBoundingAabb = ( + min: Vector3Input, + max: Vector3Input, + locale: CameraLocale = DEFAULT_CAMERA_LOCALE +): BoundingAabb => { + const aabb = Object.freeze({ kind: 'aabb', min, max }) satisfies BoundingAabb; + normalizedAabbExtents(aabb, locale); + return aabb; +}; + +export class CameraFrustum implements IDisposable { + private readonly _planes = new Float32Array(FRUSTUM_PLANE_COUNT * FRUSTUM_PLANE_COMPONENTS); + private _isDisposed = false; + + constructor(matrix?: Readonly, private readonly _locale: CameraLocale = DEFAULT_CAMERA_LOCALE) { + if (matrix) { + this.setFromMatrix(matrix); + } + } + + get isDisposed(): boolean { + return this._isDisposed; + } + + setFromMatrix(matrix: Readonly): this { + this.assertActive(); + assertMatrix(matrix, this._locale, 'matrix'); + + const data = matrix.data; + this._planes[LEFT_PLANE] = data[12]! + data[0]!; + this._planes[LEFT_PLANE + 1] = data[13]! + data[1]!; + this._planes[LEFT_PLANE + 2] = data[14]! + data[2]!; + this._planes[LEFT_PLANE + 3] = data[15]! + data[3]!; + + this._planes[RIGHT_PLANE] = data[12]! - data[0]!; + this._planes[RIGHT_PLANE + 1] = data[13]! - data[1]!; + this._planes[RIGHT_PLANE + 2] = data[14]! - data[2]!; + this._planes[RIGHT_PLANE + 3] = data[15]! - data[3]!; + + this._planes[BOTTOM_PLANE] = data[12]! + data[4]!; + this._planes[BOTTOM_PLANE + 1] = data[13]! + data[5]!; + this._planes[BOTTOM_PLANE + 2] = data[14]! + data[6]!; + this._planes[BOTTOM_PLANE + 3] = data[15]! + data[7]!; + + this._planes[TOP_PLANE] = data[12]! - data[4]!; + this._planes[TOP_PLANE + 1] = data[13]! - data[5]!; + this._planes[TOP_PLANE + 2] = data[14]! - data[6]!; + this._planes[TOP_PLANE + 3] = data[15]! - data[7]!; + + this._planes[NEAR_PLANE] = data[12]! + data[8]!; + this._planes[NEAR_PLANE + 1] = data[13]! + data[9]!; + this._planes[NEAR_PLANE + 2] = data[14]! + data[10]!; + this._planes[NEAR_PLANE + 3] = data[15]! + data[11]!; + + this._planes[FAR_PLANE] = data[12]! - data[8]!; + this._planes[FAR_PLANE + 1] = data[13]! - data[9]!; + this._planes[FAR_PLANE + 2] = data[14]! - data[10]!; + this._planes[FAR_PLANE + 3] = data[15]! - data[11]!; + + for (let offset = 0; offset < this._planes.length; offset += FRUSTUM_PLANE_COMPONENTS) { + normalizePlane(this._planes, offset, this._locale); + } + + return this; + } + + copy(other: Readonly): this { + this.assertActive(); + this._planes.set(other._planes); + return this; + } + + clone(): CameraFrustum { + this.assertActive(); + const frustum = new CameraFrustum(undefined, this._locale); + frustum.copy(this); + return frustum; + } + + copyPlane(name: FrustumPlaneName): ReadonlyTuple4 { + this.assertActive(); + const offset = this.resolvePlaneOffset(name); + return [ + this._planes[offset]!, + this._planes[offset + 1]!, + this._planes[offset + 2]!, + this._planes[offset + 3]!, + ] as const; + } + + containsPoint(point: Vector3Input): boolean { + return this.classifyPoint(point) === 'inside'; + } + + classifyPoint(point: Vector3Input): PointFrustumClassification { + this.assertActive(); + return classifyPlaneSetPoint(this._planes, point); + } + + classifySphere(sphere: Readonly): FrustumClassification { + this.assertActive(); + assertBoundingSphere(sphere, this._locale); + return classifyPlaneSetSphere(this._planes, sphere); + } + + classifyAabb(aabb: Readonly): FrustumClassification { + this.assertActive(); + return classifyPlaneSetAabb(this._planes, aabb, this._locale); + } + + classify(bounds: Readonly): FrustumClassification { + this.assertActive(); + assertBoundingVolume(bounds, this._locale); + return bounds.kind === 'sphere' + ? classifyPlaneSetSphere(this._planes, bounds) + : classifyPlaneSetAabb(this._planes, bounds, this._locale); + } + + intersectsSphere(sphere: Readonly): boolean { + return this.classifySphere(sphere) !== 'outside'; + } + + intersectsAabb(aabb: Readonly): boolean { + return this.classifyAabb(aabb) !== 'outside'; + } + + intersects(bounds: Readonly): boolean { + return this.classify(bounds) !== 'outside'; + } + + dispose(): void { + if (this._isDisposed) { + return; + } + this._planes.fill(0); + this._isDisposed = true; + } + + private resolvePlaneOffset(name: FrustumPlaneName): number { + switch (name) { + case 'left': + return LEFT_PLANE; + case 'right': + return RIGHT_PLANE; + case 'bottom': + return BOTTOM_PLANE; + case 'top': + return TOP_PLANE; + case 'near': + return NEAR_PLANE; + case 'far': + return FAR_PLANE; + } + } + + private assertActive(): void { + if (this._isDisposed) { + throw new CameraValidationError('FRUSTUM_DISPOSED', this._locale); + } + } +} \ No newline at end of file From 955e54a77e3f874cdf985fb1453b8cf35d00c946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Mon, 4 May 2026 20:32:56 +0300 Subject: [PATCH 05/25] feat: implement FrustumCuller class for efficient visibility culling with async support --- web/packages/geometry/src/culling.ts | 230 +++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 web/packages/geometry/src/culling.ts diff --git a/web/packages/geometry/src/culling.ts b/web/packages/geometry/src/culling.ts new file mode 100644 index 00000000..b155b0ca --- /dev/null +++ b/web/packages/geometry/src/culling.ts @@ -0,0 +1,230 @@ +import type { IDisposable, ReadonlyMap } from '@axrone/utility'; +import { CameraValidationError } from './camera-culling-errors'; +import { + assertBoundingVolume, + assertPositiveFiniteNumber, + DEFAULT_CAMERA_LOCALE, +} from './camera-culling-internal'; +import type { + BoundingVolume, + CameraLocale, + CullingStats, + FrustumClassification, + FrustumCullerAsyncOptions, + FrustumCullerOptions, + OverflowStrategy, +} from './camera-culling-types'; +import type { CameraFrustum } from './frustum'; + +interface MutableCullingStats extends CullingStats { + totalCount: number; + visibleCount: number; + outsideCount: number; + insideCount: number; + intersectCount: number; + skippedCount: number; + overflowed: boolean; + sphereCount: number; + aabbCount: number; + visibleSphereCount: number; + visibleAabbCount: number; +} + +const createStats = (): MutableCullingStats => ({ + totalCount: 0, + visibleCount: 0, + outsideCount: 0, + insideCount: 0, + intersectCount: 0, + skippedCount: 0, + overflowed: false, + sphereCount: 0, + aabbCount: 0, + visibleSphereCount: 0, + visibleAabbCount: 0, +}); + +const resetStats = (stats: MutableCullingStats): void => { + stats.totalCount = 0; + stats.visibleCount = 0; + stats.outsideCount = 0; + stats.insideCount = 0; + stats.intersectCount = 0; + stats.skippedCount = 0; + stats.overflowed = false; + stats.sphereCount = 0; + stats.aabbCount = 0; + stats.visibleSphereCount = 0; + stats.visibleAabbCount = 0; +}; + +const nextMicrotask = (): Promise => + new Promise((resolve) => { + queueMicrotask(resolve); + }); + +export class FrustumCuller + implements IDisposable +{ + private readonly _visible: TItem[] = []; + private readonly _stats: MutableCullingStats = createStats(); + private readonly _classifications?: Map; + private readonly _locale: CameraLocale; + private readonly _maxResults: number; + private readonly _overflow: OverflowStrategy; + private readonly _asyncBatchSize: number; + private _isDisposed = false; + + constructor(private readonly _options: Readonly>) { + this._locale = _options.locale ?? DEFAULT_CAMERA_LOCALE; + this._maxResults = Math.max(0, _options.maxResults ?? Number.POSITIVE_INFINITY); + this._overflow = _options.overflow ?? 'trim'; + this._asyncBatchSize = Math.max(1, _options.asyncBatchSize ?? 1024); + if (Number.isFinite(this._maxResults)) { + assertPositiveFiniteNumber( + this._maxResults, + this._locale, + 'maxResults', + 'INVALID_ARGUMENT' + ); + } + if (_options.trackClassifications === true) { + this._classifications = new Map(); + } + } + + get isDisposed(): boolean { + return this._isDisposed; + } + + get visible(): readonly TItem[] { + return this._visible; + } + + get stats(): Readonly { + return this._stats; + } + + get classifications(): ReadonlyMap | undefined { + return this._classifications; + } + + reset(): this { + this.assertActive(); + this._visible.length = 0; + this._classifications?.clear(); + resetStats(this._stats); + return this; + } + + cull(items: Iterable, frustum: Readonly): this { + this.assertActive(); + this.reset(); + for (const item of items) { + this.processItem(item, frustum); + } + if (this._options.sort && this._visible.length > 1) { + this._visible.sort(this._options.sort); + } + return this; + } + + async cullAsync(items: Iterable, frustum: Readonly, options: Readonly = {}): Promise { + this.assertActive(); + this.reset(); + + const batchSize = Math.max(1, options.batchSize ?? this._asyncBatchSize); + let processed = 0; + + for (const item of items) { + if (options.signal?.aborted === true) { + throw new CameraValidationError('OPERATION_ABORTED', this._locale); + } + this.processItem(item, frustum); + processed += 1; + + if (processed % batchSize === 0) { + const scheduler = options.scheduler; + if (scheduler) { + await scheduler(); + } else { + await nextMicrotask(); + } + } + } + + if (this._options.sort && this._visible.length > 1) { + this._visible.sort(this._options.sort); + } + return this; + } + + dispose(): void { + if (this._isDisposed) { + return; + } + this._visible.length = 0; + this._classifications?.clear(); + resetStats(this._stats); + this._isDisposed = true; + } + + private processItem(item: TItem, frustum: Readonly): void { + if (this._options.filter && this._options.filter(item) === false) { + this._stats.skippedCount += 1; + return; + } + + const bounds = this._options.bounds(item); + if (!bounds) { + this._stats.skippedCount += 1; + return; + } + + assertBoundingVolume(bounds, this._locale); + this._stats.totalCount += 1; + if (bounds.kind === 'sphere') { + this._stats.sphereCount += 1; + } else { + this._stats.aabbCount += 1; + } + + const classification = frustum.classify(bounds); + this._classifications?.set(item, classification); + + if (classification === 'outside') { + this._stats.outsideCount += 1; + return; + } + + if (classification === 'inside') { + this._stats.insideCount += 1; + } else { + this._stats.intersectCount += 1; + } + + if (this._visible.length >= this._maxResults) { + this._stats.overflowed = true; + if (this._overflow === 'throw') { + throw new CameraValidationError('RESULT_OVERFLOW', this._locale, { + maxResults: this._maxResults, + }); + } + return; + } + + this._visible.push(item); + this._stats.visibleCount += 1; + if (bounds.kind === 'sphere') { + this._stats.visibleSphereCount += 1; + } else { + this._stats.visibleAabbCount += 1; + } + } + + private assertActive(): void { + if (this._isDisposed) { + throw new CameraValidationError('CULLER_DISPOSED', this._locale); + } + } +} \ No newline at end of file From 6e7391634de9d3e419eb4bf85c97f0ad800c8552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Mon, 4 May 2026 20:33:17 +0300 Subject: [PATCH 06/25] feat: implement Camera3D class with projection handling and frustum culling --- web/packages/geometry/src/camera.ts | 272 ++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 web/packages/geometry/src/camera.ts diff --git a/web/packages/geometry/src/camera.ts b/web/packages/geometry/src/camera.ts new file mode 100644 index 00000000..e1d81e3c --- /dev/null +++ b/web/packages/geometry/src/camera.ts @@ -0,0 +1,272 @@ +import { Mat4, Vec3, type IMat4Like, type IVec3Like } from '@axrone/numeric'; +import type { IDisposable } from '@axrone/utility'; +import { CameraSerializationError, CameraValidationError } from './camera-culling-errors'; +import { + buildProjectionMatrix, + buildViewMatrix, + cloneProjection, + copyVector3, + createCameraId, + DEFAULT_CAMERA_LOCALE, + DEFAULT_UP_VECTOR, + resolveSerializedCamera, + toMatrix4Tuple, + toResolvedPose, +} from './camera-culling-internal'; +import type { + BoundingVolume, + CameraId, + CameraLocale, + CameraOptions, + CameraOptionsOf, + CameraOrthographicProjection, + CameraPerspectiveProjection, + CameraProjection, + CameraSerialized, + FrustumClassification, + Vector3Input, +} from './camera-culling-types'; +import { CameraFrustum } from './frustum'; + +export class Camera3D implements IDisposable { + private readonly _position = Vec3.ZERO.clone(); + private readonly _target = Vec3.BACK.clone(); + private readonly _up = Vec3.UP.clone(); + private readonly _viewMatrix = new Mat4(); + private readonly _projectionMatrix = new Mat4(); + private readonly _viewProjectionMatrix = new Mat4(); + private readonly _frustum: CameraFrustum; + + private _isDisposed = false; + private _dirty = true; + private _id: CameraId; + private _locale: CameraLocale; + private _projection: TProjection; + + constructor(options: Readonly>) { + this._locale = options.locale ?? DEFAULT_CAMERA_LOCALE; + this._id = createCameraId(options.id, this._locale); + this._projection = cloneProjection(options.projection, this._locale); + this._frustum = new CameraFrustum(undefined, this._locale); + this.setPose(options.pose); + } + + static perspective(options: Readonly>): Camera3D { + return new Camera3D(options); + } + + static orthographic( + options: Readonly> + ): Camera3D { + return new Camera3D(options); + } + + static fromJSON( + value: unknown, + locale: CameraLocale = DEFAULT_CAMERA_LOCALE + ): Camera3D { + const serialized = resolveSerializedCamera(value, locale); + try { + return new Camera3D({ + id: serialized.id, + locale: serialized.locale, + projection: serialized.projection, + pose: serialized.pose, + }); + } catch (error) { + throw new CameraSerializationError(locale, { value }, error); + } + } + + get isDisposed(): boolean { + return this._isDisposed; + } + + get id(): CameraId { + return this._id; + } + + get locale(): CameraLocale { + return this._locale; + } + + get projection(): Readonly { + return this._projection; + } + + get near(): number { + return this._projection.near; + } + + get far(): number { + return this._projection.far; + } + + get position(): Readonly { + return this._position; + } + + get target(): Readonly { + return this._target; + } + + get up(): Readonly { + return this._up; + } + + get viewMatrix(): Readonly { + this.assertActive(); + this.synchronize(); + return this._viewMatrix; + } + + get projectionMatrix(): Readonly { + this.assertActive(); + this.synchronize(); + return this._projectionMatrix; + } + + get viewProjectionMatrix(): Readonly { + this.assertActive(); + this.synchronize(); + return this._viewProjectionMatrix; + } + + get frustum(): Readonly { + this.assertActive(); + this.synchronize(); + return this._frustum; + } + + isPerspective(): this is Camera3D { + return this._projection.kind === 'perspective'; + } + + isOrthographic(): this is Camera3D { + return this._projection.kind === 'orthographic'; + } + + setProjection(projection: Readonly): this { + this.assertActive(); + this._projection = cloneProjection(projection, this._locale); + this._dirty = true; + return this; + } + + setLocale(locale: CameraLocale): this { + this.assertActive(); + this._locale = locale; + this._dirty = true; + return this; + } + + setPose(pose: Readonly<{ position: Vector3Input; target: Vector3Input; up?: Vector3Input }>): this { + this.assertActive(); + copyVector3(pose.position, this._position); + copyVector3(pose.target, this._target); + copyVector3(pose.up ?? DEFAULT_UP_VECTOR, this._up); + this._dirty = true; + return this; + } + + lookAt(position: Vector3Input, target: Vector3Input, up: Vector3Input = DEFAULT_UP_VECTOR): this { + return this.setPose({ position, target, up }); + } + + classify(bounds: Readonly): FrustumClassification { + this.assertActive(); + this.synchronize(); + return this._frustum.classify(bounds); + } + + intersects(bounds: Readonly): boolean { + return this.classify(bounds) !== 'outside'; + } + + clone(): Camera3D { + this.assertActive(); + return new Camera3D({ + id: this._id, + locale: this._locale, + projection: this._projection, + pose: { + position: this._position, + target: this._target, + up: this._up, + }, + }); + } + + cloneWithProjection( + projection: Readonly + ): Camera3D { + this.assertActive(); + return new Camera3D({ + id: this._id, + locale: this._locale, + projection, + pose: { + position: this._position, + target: this._target, + up: this._up, + }, + }); + } + + toJSON(): CameraSerialized { + this.assertActive(); + this.synchronize(); + return Object.freeze({ + id: this._id, + locale: this._locale, + projection: this._projection, + pose: toResolvedPose(this._position, this._target, this._up), + viewMatrix: toMatrix4Tuple(this._viewMatrix), + projectionMatrix: toMatrix4Tuple(this._projectionMatrix), + viewProjectionMatrix: toMatrix4Tuple(this._viewProjectionMatrix), + }); + } + + dispose(): void { + if (this._isDisposed) { + return; + } + + this._frustum.dispose(); + this._position.x = 0; + this._position.y = 0; + this._position.z = 0; + this._target.x = 0; + this._target.y = 0; + this._target.z = 0; + this._up.x = 0; + this._up.y = 0; + this._up.z = 0; + this._isDisposed = true; + } + + private synchronize(): void { + if (!this._dirty) { + return; + } + + try { + buildViewMatrix(this._position, this._target, this._up, this._viewMatrix, this._locale); + buildProjectionMatrix(this._projection, this._projectionMatrix, this._locale); + Mat4.multiply(this._projectionMatrix, this._viewMatrix, this._viewProjectionMatrix); + this._frustum.setFromMatrix(this._viewProjectionMatrix); + this._dirty = false; + } catch (error) { + if (error instanceof CameraValidationError) { + throw error; + } + throw new CameraValidationError('INVALID_POSE', this._locale, {}, error); + } + } + + private assertActive(): void { + if (this._isDisposed) { + throw new CameraValidationError('CAMERA_DISPOSED', this._locale); + } + } +} \ No newline at end of file From e3fb0c816104d4ded3aa5542f248d31f6b710385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Mon, 4 May 2026 20:33:28 +0300 Subject: [PATCH 07/25] feat: add unit tests for camera frustum culling and enhance module exports --- .../__tests__/camera-frustum-culling.test.ts | 139 ++++++++++++++++++ web/packages/geometry/src/index.ts | 5 + 2 files changed, 144 insertions(+) create mode 100644 web/packages/geometry/src/__tests__/camera-frustum-culling.test.ts diff --git a/web/packages/geometry/src/__tests__/camera-frustum-culling.test.ts b/web/packages/geometry/src/__tests__/camera-frustum-culling.test.ts new file mode 100644 index 00000000..d0c68e7f --- /dev/null +++ b/web/packages/geometry/src/__tests__/camera-frustum-culling.test.ts @@ -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({ + 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'); + }); +}); \ No newline at end of file diff --git a/web/packages/geometry/src/index.ts b/web/packages/geometry/src/index.ts index 65628834..f8983a65 100644 --- a/web/packages/geometry/src/index.ts +++ b/web/packages/geometry/src/index.ts @@ -1,3 +1,8 @@ export * from './aabb'; +export * from './camera-culling-errors'; +export * from './camera-culling-types'; +export * from './camera'; +export * from './culling'; +export * from './frustum'; export * from './primitives'; export * from './spatial'; From 66b7b3848fe8df5a54a8255ec0b8d1208d0e37c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Mon, 4 May 2026 20:44:11 +0300 Subject: [PATCH 08/25] feat: enhance RenderFrameClassifier with camera frustum intersection checks --- web/packages/render-core/package.json | 1 + .../src/render-frame-classifier.ts | 48 +++++++++++++++++-- web/packages/render-core/src/types.ts | 3 ++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/web/packages/render-core/package.json b/web/packages/render-core/package.json index 8b022f87..ab29dc74 100644 --- a/web/packages/render-core/package.json +++ b/web/packages/render-core/package.json @@ -21,6 +21,7 @@ "test": "vitest run" }, "dependencies": { + "@axrone/geometry": "^0.1.0", "@axrone/numeric": "^0.0.1" } } \ No newline at end of file diff --git a/web/packages/render-core/src/render-frame-classifier.ts b/web/packages/render-core/src/render-frame-classifier.ts index 82246748..d9d20897 100644 --- a/web/packages/render-core/src/render-frame-classifier.ts +++ b/web/packages/render-core/src/render-frame-classifier.ts @@ -1,4 +1,4 @@ -import type { Mat4 } from '@axrone/numeric'; +import { Vec3, type Mat4 } from '@axrone/numeric'; import { ReusableList, SortableRenderList, StringKeyCache } from './memory'; import type { ReadonlyRenderList, @@ -30,9 +30,11 @@ const getX = (value: RenderVec3Ref): number => (Array.isArray(value) ? value[0] const getY = (value: RenderVec3Ref): number => (Array.isArray(value) ? value[1] : asObjectVec3(value).y); const getZ = (value: RenderVec3Ref): number => (Array.isArray(value) ? value[2] : asObjectVec3(value).z); -const getTranslationX = (matrix: Mat4): number => matrix.data[12]; -const getTranslationY = (matrix: Mat4): number => matrix.data[13]; -const getTranslationZ = (matrix: Mat4): number => matrix.data[14]; +const getTranslationX = (matrix: Mat4): number => matrix.data[3]; +const getTranslationY = (matrix: Mat4): number => matrix.data[7]; +const getTranslationZ = (matrix: Mat4): number => matrix.data[11]; + +const resolveCameraFrustum = (camera: RenderCameraState) => camera.frustum ?? camera.camera3D?.frustum; const layerVisible = (cameraMask: number, primitiveMask: number | undefined): boolean => primitiveMask === undefined || primitiveMask === 0 || (cameraMask & primitiveMask) !== 0; @@ -110,6 +112,13 @@ const renderQueueFor = (primitive: RenderPrimitiveInstance): number => { export class RenderFrameClassifier { private readonly _strings = new StringKeyCache(); + private readonly _primitiveFrustumMin = new Vec3(); + private readonly _primitiveFrustumMax = new Vec3(); + private readonly _primitiveFrustumBounds = { + kind: 'aabb' as const, + min: this._primitiveFrustumMin, + max: this._primitiveFrustumMax, + }; private readonly _opaque = new SortableRenderList(256); private readonly _transparent = new SortableRenderList(128); private readonly _shadowCasters = new SortableRenderList(256); @@ -183,6 +192,7 @@ export class RenderFrameClassifier { private _classifyPrimitives(input: RenderFrameInput, warnings: ReusableList): void { const cameraMask = input.camera.layerMask ?? -1; + const frustum = resolveCameraFrustum(input.camera); for (const primitive of input.primitives) { if (primitive.visible === false) { continue; @@ -192,6 +202,10 @@ export class RenderFrameClassifier { continue; } + if (frustum && primitive.bounds && !this._intersectsCameraFrustum(primitive, frustum)) { + continue; + } + const queue = renderQueueFor(primitive); const materialKey = this._strings.get(primitive.material.id); const meshKey = this._strings.get(primitive.meshId); @@ -227,6 +241,32 @@ export class RenderFrameClassifier { this._shadowCasters.sort(OPAQUE_SORT); } + private _intersectsCameraFrustum( + primitive: RenderPrimitiveInstance, + frustum: NonNullable> + ): boolean { + const bounds = primitive.bounds; + if (!bounds) { + return true; + } + + const centerX = getX(bounds.center); + const centerY = getY(bounds.center); + const centerZ = getZ(bounds.center); + const extentX = getX(bounds.extents); + const extentY = getY(bounds.extents); + const extentZ = getZ(bounds.extents); + + this._primitiveFrustumMin.x = centerX - extentX; + this._primitiveFrustumMin.y = centerY - extentY; + this._primitiveFrustumMin.z = centerZ - extentZ; + this._primitiveFrustumMax.x = centerX + extentX; + this._primitiveFrustumMax.y = centerY + extentY; + this._primitiveFrustumMax.z = centerZ + extentZ; + + return frustum.intersectsAabb(this._primitiveFrustumBounds); + } + private _classifyLights(input: RenderFrameInput): void { for (const light of input.lights ?? []) { if (light.type === 'directional') { diff --git a/web/packages/render-core/src/types.ts b/web/packages/render-core/src/types.ts index 0301ebf5..90f966f6 100644 --- a/web/packages/render-core/src/types.ts +++ b/web/packages/render-core/src/types.ts @@ -1,3 +1,4 @@ +import type { Camera3D, CameraFrustum } from '@axrone/geometry'; import type { Mat4, Vec3, Vec4 } from '@axrone/numeric'; import type { IDisposable } from './disposable'; @@ -215,6 +216,8 @@ export interface RenderCameraState { readonly viewMatrix: Mat4; readonly projectionMatrix: Mat4; readonly viewProjectionMatrix?: Mat4; + readonly camera3D?: Readonly; + readonly frustum?: Readonly; readonly position: RenderVector3Like; readonly near: number; readonly far: number; From ded1d10bb63adb4b624d151a91f154d157d8d4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Mon, 4 May 2026 20:44:28 +0300 Subject: [PATCH 09/25] feat: add culling camera and bounded primitive creation for frustum culling tests --- .../src/__tests__/render-pipeline.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/web/packages/render-core/src/__tests__/render-pipeline.test.ts b/web/packages/render-core/src/__tests__/render-pipeline.test.ts index 2c7d59d8..4bf26fd5 100644 --- a/web/packages/render-core/src/__tests__/render-pipeline.test.ts +++ b/web/packages/render-core/src/__tests__/render-pipeline.test.ts @@ -1,3 +1,4 @@ +import { Camera3D } from '@axrone/geometry'; import { Mat4 } from '@axrone/numeric'; import { describe, expect, it } from 'vitest'; import { RenderPipeline } from '@axrone/render-core'; @@ -40,6 +41,55 @@ const createTransparentPrimitive = (id: string = 'transparent') => ({ }, }); +const createBoundedPrimitive = ( + id: string, + center: readonly [number, number, number], + extents: readonly [number, number, number] +) => ({ + id: `primitive:${id}`, + meshId: `mesh:${id}`, + worldMatrix: new Mat4(), + bounds: { + center, + extents, + }, + material: { + id: `material:${id}`, + model: 'pbr' as const, + renderQueue: 2000, + castsShadows: true, + }, +}); + +const createCullingCamera = () => { + const camera3D = Camera3D.perspective({ + id: 'camera:culling', + projection: { + kind: 'perspective', + verticalFieldOfView: Math.PI / 3, + aspectRatio: 1, + near: 0.1, + far: 100, + }, + pose: { + position: [0, 0, 0], + target: [0, 0, -1], + }, + }); + + return { + id: 'camera:culling', + viewMatrix: Mat4.from(camera3D.viewMatrix), + projectionMatrix: Mat4.from(camera3D.projectionMatrix), + viewProjectionMatrix: Mat4.from(camera3D.viewProjectionMatrix), + camera3D, + frustum: camera3D.frustum, + position: camera3D.position, + near: camera3D.near, + far: camera3D.far, + }; +}; + describe('RenderPipeline', () => { it('plans an integrated frame with core rendering features', () => { const pipeline = new RenderPipeline({ @@ -159,6 +209,24 @@ describe('RenderPipeline', () => { expect(second.statistics.resourceReuseCount).toBeGreaterThan(0); }); + it('culls bounded primitives outside the provided camera frustum', () => { + const pipeline = new RenderPipeline(); + + const result = pipeline.plan({ + frame: 1, + deltaTime: 1 / 60, + viewport: { width: 1024, height: 1024 }, + camera: createCullingCamera(), + primitives: [ + createBoundedPrimitive('inside', [0, 0, -4], [0.5, 0.5, 0.5]), + createBoundedPrimitive('outside', [25, 0, -4], [0.5, 0.5, 0.5]), + ], + }); + + expect(result.statistics.opaqueCount).toBe(1); + expect(result.passes.some((pass) => pass.kind === 'opaque')).toBe(true); + }); + it('gracefully degrades optional features under a constrained budget', () => { const pipeline = new RenderPipeline({ frameBudgetMs: 0.4, From 270fa93a5612e16383d41de97ce5c7ad8a5240e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Mon, 4 May 2026 20:45:26 +0300 Subject: [PATCH 10/25] feat: integrate Camera3D for enhanced view and projection matrix handling in SceneCameraFrameState Co-authored-by: Copilot --- .../scene-runtime/src/camera-frame-state.ts | 79 +++-------- .../scene-runtime/src/components/camera.ts | 123 ++++++++++++------ .../scene-runtime/src/scene-render-runtime.ts | 13 +- 3 files changed, 103 insertions(+), 112 deletions(-) diff --git a/web/packages/scene-runtime/src/camera-frame-state.ts b/web/packages/scene-runtime/src/camera-frame-state.ts index 9ff14150..23777aec 100644 --- a/web/packages/scene-runtime/src/camera-frame-state.ts +++ b/web/packages/scene-runtime/src/camera-frame-state.ts @@ -1,9 +1,10 @@ -import { Mat4, Quat, Vec3 } from '@axrone/numeric'; -import { Transform } from '@axrone/ecs-runtime'; -import { Camera, resolveCameraVerticalFieldOfViewRadians } from './components/camera'; +import { type Camera3D, type CameraProjection } from '@axrone/geometry'; +import { Mat4, type IMat4Like, Vec3 } from '@axrone/numeric'; +import { Camera } from './components/camera'; interface MutableSceneCameraFrameState { camera: Camera; + camera3D: Readonly>; readonly viewMatrix: Mat4; readonly projectionMatrix: Mat4; readonly viewProjectionMatrix: Mat4; @@ -12,7 +13,7 @@ interface MutableSceneCameraFrameState { export type SceneCameraFrameState = Readonly; -const copyMat4 = (source: Readonly, target: Mat4): Mat4 => { +const copyMat4 = (source: Readonly, target: Mat4): Mat4 => { const sourceData = source.data; const targetData = target.data; @@ -24,12 +25,9 @@ const copyMat4 = (source: Readonly, target: Mat4): Mat4 => { }; export class SceneCameraFrameStateCollector { - private readonly _inverseRotation = new Quat(); - private readonly _inverseTranslation = new Vec3(); - private readonly _rotationMatrix = new Mat4(); - private readonly _translationMatrix = new Mat4(); private readonly _state: MutableSceneCameraFrameState = { camera: null as unknown as Camera, + camera3D: null as unknown as Readonly>, viewMatrix: new Mat4(), projectionMatrix: new Mat4(), viewProjectionMatrix: new Mat4(), @@ -46,63 +44,16 @@ export class SceneCameraFrameStateCollector { } this._state.camera = camera; - - const transform = camera.transform as Transform | undefined; - if (!transform) { - copyMat4(Mat4.IDENTITY, this._state.viewMatrix); - this._state.position.x = 0; - this._state.position.y = 0; - this._state.position.z = 0; - } else { - const worldPosition = transform.worldPosition; - const worldRotation = transform.worldRotation; - - this._state.position.x = worldPosition.x; - this._state.position.y = worldPosition.y; - this._state.position.z = worldPosition.z; - - Quat.inverse(worldRotation, this._inverseRotation); - this._inverseTranslation.x = -worldPosition.x; - this._inverseTranslation.y = -worldPosition.y; - this._inverseTranslation.z = -worldPosition.z; - - Mat4.fromQuaternion(this._inverseRotation, this._rotationMatrix); - Mat4.translate(this._inverseTranslation, this._translationMatrix); - Mat4.multiply(this._rotationMatrix, this._translationMatrix, this._state.viewMatrix); - } - const aspectRatio = viewportWidth / Math.max(1, viewportHeight); - if (camera.orthographic) { - const halfHeight = camera.orthographicSize; - const halfWidth = halfHeight * aspectRatio; - Mat4.orthographic( - -halfWidth, - halfWidth, - -halfHeight, - halfHeight, - camera.near, - camera.far, - this._state.projectionMatrix - ); - } else { - Mat4.perspective( - resolveCameraVerticalFieldOfViewRadians( - camera.fieldOfView, - camera.fieldOfViewAxis, - aspectRatio - ), - aspectRatio, - camera.near, - camera.far, - this._state.projectionMatrix - ); - } - - Mat4.multiply( - this._state.projectionMatrix, - this._state.viewMatrix, - this._state.viewProjectionMatrix - ); + const camera3D = camera.getRuntimeCamera(aspectRatio); + this._state.camera3D = camera3D; + + copyMat4(camera3D.viewMatrix, this._state.viewMatrix); + copyMat4(camera3D.projectionMatrix, this._state.projectionMatrix); + copyMat4(camera3D.viewProjectionMatrix, this._state.viewProjectionMatrix); + this._state.position.x = camera3D.position.x; + this._state.position.y = camera3D.position.y; + this._state.position.z = camera3D.position.z; return this._state; } diff --git a/web/packages/scene-runtime/src/components/camera.ts b/web/packages/scene-runtime/src/components/camera.ts index 6c7dca75..279e1080 100644 --- a/web/packages/scene-runtime/src/components/camera.ts +++ b/web/packages/scene-runtime/src/components/camera.ts @@ -1,4 +1,5 @@ -import { Mat4, Vec3, Vec4 } from '@axrone/numeric'; +import { Camera3D, type CameraProjection } from '@axrone/geometry'; +import { Mat4, Quat, Vec3, Vec4 } from '@axrone/numeric'; import { Transform } from '@axrone/ecs-runtime'; import { Component } from '@axrone/ecs-runtime'; import { script } from '@axrone/ecs-runtime'; @@ -78,6 +79,10 @@ const toVec4 = (value?: Vec4 | readonly [number, number, number, number]): Vec4 singleton: false, }) export class Camera extends Component { + private readonly _runtimeForward = new Vec3(); + private readonly _runtimeTarget = new Vec3(); + private readonly _runtimeUp = new Vec3(); + private _primary: boolean; private _near: number; private _far: number; @@ -88,6 +93,7 @@ export class Camera extends Component { private _clearFlags: SceneClearFlag[]; private _clearDepth: number; private _clearColor: Vec4; + private _runtimeCamera: Camera3D | null = null; constructor(config: CameraConfig = {}) { super(); @@ -184,59 +190,94 @@ export class Camera extends Component { } getViewMatrix(): Mat4 { - const transform = this.transform as Transform | undefined; - - if (!transform) { - return Mat4.IDENTITY.clone(); - } - - const worldPosition = transform.worldPosition; - const inverseRotation = transform.worldRotation.clone().inverse(); - const inverseTranslation = new Vec3(-worldPosition.x, -worldPosition.y, -worldPosition.z); - - return Mat4.multiply( - Mat4.fromQuaternion(inverseRotation), - Mat4.translate(inverseTranslation) - ); + return Mat4.from(this.getRuntimeCamera(1).viewMatrix); } getProjectionMatrix(aspectRatio: number): Mat4 { - if (this._orthographic) { - const halfHeight = this._orthographicSize; - const halfWidth = halfHeight * aspectRatio; - return Mat4.orthographic( - -halfWidth, - halfWidth, - -halfHeight, - halfHeight, - this._near, - this._far - ); - } - - return Mat4.perspective( - resolveCameraVerticalFieldOfViewRadians( - this._fieldOfView, - this._fieldOfViewAxis, - aspectRatio - ), - aspectRatio, - this._near, - this._far - ); + return Mat4.from(this.getRuntimeCamera(aspectRatio).projectionMatrix); } getViewProjectionMatrix(aspectRatio: number): Mat4 { - return Mat4.multiply(this.getProjectionMatrix(aspectRatio), this.getViewMatrix()); + return Mat4.from(this.getRuntimeCamera(aspectRatio).viewProjectionMatrix); } getWorldPosition(): Vec3 { + return Vec3.from(this.getRuntimeCamera(1).position); + } + + getRuntimeCamera(aspectRatio: number): Readonly> { + const safeAspectRatio = Math.max(aspectRatio, 0.001); + const projection = this._orthographic + ? { + kind: 'orthographic' as const, + left: -this._orthographicSize * safeAspectRatio, + right: this._orthographicSize * safeAspectRatio, + bottom: -this._orthographicSize, + top: this._orthographicSize, + near: this._near, + far: this._far, + } + : { + kind: 'perspective' as const, + verticalFieldOfView: resolveCameraVerticalFieldOfViewRadians( + this._fieldOfView, + this._fieldOfViewAxis, + safeAspectRatio + ), + aspectRatio: safeAspectRatio, + near: this._near, + far: this._far, + }; + const transform = this.transform as Transform | undefined; if (!transform) { - return Vec3.ZERO.clone(); + this._runtimeTarget.x = 0; + this._runtimeTarget.y = 0; + this._runtimeTarget.z = -1; + this._runtimeUp.x = 0; + this._runtimeUp.y = 1; + this._runtimeUp.z = 0; + + if (!this._runtimeCamera) { + this._runtimeCamera = new Camera3D({ + id: `camera:${this.id}`, + projection, + pose: { + position: Vec3.ZERO, + target: this._runtimeTarget, + up: this._runtimeUp, + }, + }); + } else { + this._runtimeCamera.setProjection(projection); + this._runtimeCamera.lookAt(Vec3.ZERO, this._runtimeTarget, this._runtimeUp); + } + + return this._runtimeCamera; + } + + const worldPosition = transform.worldPosition; + const worldRotation = transform.worldRotation; + Quat.rotateVector(worldRotation, Vec3.BACK, this._runtimeForward); + Quat.rotateVector(worldRotation, Vec3.UP, this._runtimeUp); + Vec3.add(worldPosition, this._runtimeForward, this._runtimeTarget); + + if (!this._runtimeCamera) { + this._runtimeCamera = new Camera3D({ + id: `camera:${this.id}`, + projection, + pose: { + position: worldPosition, + target: this._runtimeTarget, + up: this._runtimeUp, + }, + }); + } else { + this._runtimeCamera.setProjection(projection); + this._runtimeCamera.lookAt(worldPosition, this._runtimeTarget, this._runtimeUp); } - return transform.worldPosition.clone(); + return this._runtimeCamera; } override serialize(): Record { diff --git a/web/packages/scene-runtime/src/scene-render-runtime.ts b/web/packages/scene-runtime/src/scene-render-runtime.ts index 3571ac54..4797f267 100644 --- a/web/packages/scene-runtime/src/scene-render-runtime.ts +++ b/web/packages/scene-runtime/src/scene-render-runtime.ts @@ -141,24 +141,23 @@ export class SceneRenderRuntime { const renderFrame = this._renderFrameState.begin(params.frame); const actors = this._options.getActors(); const camera = selectSceneCamera(actors); + const cameraFrame = this._cameraFrameCollector.collect( + camera, + params.viewportWidth, + params.viewportHeight + ); const lighting = this._lightingCollector.collect( actors, this._options.ambientLight, this._options.skyLight, this._options.groundLight, - (camera?.transform as Transform | undefined)?.worldPosition + cameraFrame?.position ); const renderPasses = this._options.resources.renderPasses.getEnabledResources(); if (renderPasses.length === 0) { return; } - - const cameraFrame = this._cameraFrameCollector.collect( - camera, - params.viewportWidth, - params.viewportHeight - ); this._options.gl.viewport(0, 0, params.viewportWidth, params.viewportHeight); for (const renderPass of renderPasses) { From c1de060aeb90ee6d76787a962f50a349a327fc97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Mon, 4 May 2026 20:45:40 +0300 Subject: [PATCH 11/25] feat: enhance SceneCameraFrameStateCollector tests with additional camera3D checks --- web/packages/scene-3d/src/__tests__/camera-frame-state.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/packages/scene-3d/src/__tests__/camera-frame-state.test.ts b/web/packages/scene-3d/src/__tests__/camera-frame-state.test.ts index e844f0d1..f894cdf1 100644 --- a/web/packages/scene-3d/src/__tests__/camera-frame-state.test.ts +++ b/web/packages/scene-3d/src/__tests__/camera-frame-state.test.ts @@ -31,11 +31,13 @@ describe('SceneCameraFrameStateCollector', () => { expect(second?.projectionMatrix).toBe(first?.projectionMatrix); expect(second?.viewProjectionMatrix).toBe(first?.viewProjectionMatrix); expect(second?.position).toBe(first?.position); + expect(second?.camera3D).toBe(first?.camera3D); expect(second?.viewMatrix.equals(camera.getViewMatrix())).toBe(true); expect(second?.projectionMatrix.equals(camera.getProjectionMatrix(1920 / 1080))).toBe(true); expect(second?.viewProjectionMatrix.equals(camera.getViewProjectionMatrix(1920 / 1080))).toBe( true ); + expect(second?.camera3D).toBe(camera.getRuntimeCamera(1920 / 1080)); expect(second?.position.equals(transform.worldPosition)).toBe(true); }); From 2d625f636ee6e39fa45e9ea4234fccc791439d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Tue, 5 May 2026 06:17:02 +0300 Subject: [PATCH 12/25] feat: add benchmark for frustum culling performance --- .../geometry/bench/camera-culling.bench.ts | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 web/packages/geometry/bench/camera-culling.bench.ts diff --git a/web/packages/geometry/bench/camera-culling.bench.ts b/web/packages/geometry/bench/camera-culling.bench.ts new file mode 100644 index 00000000..670b0083 --- /dev/null +++ b/web/packages/geometry/bench/camera-culling.bench.ts @@ -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({ + 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 => { + 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 => { + 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; +}); \ No newline at end of file From 11bda4ad5282d02b7fdc31740ab2e36492635049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Tue, 5 May 2026 06:17:56 +0300 Subject: [PATCH 13/25] feat: enhance ParticleSystemRenderer with Camera3D and CameraFrustum support frustum culling --- web/packages/geometry/package.json | 1 + .../particle-system/src/particle-renderer.ts | 33 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/web/packages/geometry/package.json b/web/packages/geometry/package.json index 1060c6fa..217815dc 100644 --- a/web/packages/geometry/package.json +++ b/web/packages/geometry/package.json @@ -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" diff --git a/web/packages/particle-system/src/particle-renderer.ts b/web/packages/particle-system/src/particle-renderer.ts index 2c320dc4..dc5ff724 100644 --- a/web/packages/particle-system/src/particle-renderer.ts +++ b/web/packages/particle-system/src/particle-renderer.ts @@ -1,3 +1,4 @@ +import type { Camera3D, CameraFrustum } from '@axrone/geometry'; import { IVec4Array, IVec3Array } from './aligned-arrays'; import { ParticleSOA } from './particle-soa'; import { SortMode } from './types'; @@ -138,6 +139,18 @@ export interface RenderSettings { occlusionCulling: boolean; } +type FrustumSource = Float32Array | Readonly | Readonly; + +const isFloat32Array = (value: FrustumSource): value is Float32Array => value instanceof Float32Array; + +const isCameraFrustum = (value: FrustumSource): value is Readonly => + typeof value === 'object' && value !== null && 'copyPlane' in value; + +const resolveCameraFrustum = (value: FrustumSource): Readonly => + (isCameraFrustum(value) ? value : value.frustum) as Readonly; + +const FRUSTUM_PLANE_NAMES = ['left', 'right', 'bottom', 'top', 'near', 'far'] as const; + export class ParticleSystemRenderer { private readonly _settings: RenderSettings; private readonly _renderBatches: IRenderBatch[] = []; @@ -213,10 +226,26 @@ export class ParticleSystemRenderer { } } - updateFrustum(viewProjectionMatrix: Float32Array): void { + updateFrustum(source: FrustumSource): void { if (!this._settings.frustumCulling) return; - this._extractFrustumPlanes(viewProjectionMatrix); + if (isFloat32Array(source)) { + this._extractFrustumPlanes(source); + return; + } + + this._copyGeometryFrustum(resolveCameraFrustum(source)); + } + + private _copyGeometryFrustum(frustum: Readonly): void { + for (let planeIndex = 0; planeIndex < FRUSTUM_PLANE_NAMES.length; planeIndex++) { + const plane = frustum.copyPlane(FRUSTUM_PLANE_NAMES[planeIndex]); + const offset = planeIndex * 4; + this._frustumPlanes[offset] = plane[0]; + this._frustumPlanes[offset + 1] = plane[1]; + this._frustumPlanes[offset + 2] = plane[2]; + this._frustumPlanes[offset + 3] = plane[3]; + } } private _extractFrustumPlanes(matrix: Float32Array): void { From 7fe62f8d530d6aa875c16a35abb5a3e098c790fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Tue, 5 May 2026 06:18:37 +0300 Subject: [PATCH 14/25] feat: optimize depth calculation in BatchRenderer using getTranslationZ helper --- web/packages/render-webgl2/src/batch/batch-renderer.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/packages/render-webgl2/src/batch/batch-renderer.ts b/web/packages/render-webgl2/src/batch/batch-renderer.ts index 5a9cc0a5..12d9d2af 100644 --- a/web/packages/render-webgl2/src/batch/batch-renderer.ts +++ b/web/packages/render-webgl2/src/batch/batch-renderer.ts @@ -4,6 +4,8 @@ import { IBatchable, IBatchRenderer, BatchConfiguration } from './interfaces'; import { IMaterialInstance } from '../shader/interfaces'; import { BatchGroup } from './batch-group'; +const getTranslationZ = (matrix: Mat4): number => matrix.data[11]; + interface BatchJob { group: BatchGroup; priority: number; @@ -250,9 +252,8 @@ export class BatchRenderer implements IBatchRenderer { for (const instance of group.instances) { if (instance.visible) { - const worldPos = instance.worldMatrix.data.slice(12, 15); - const viewPos = viewMatrix.multiply(instance.worldMatrix).data.slice(12, 15); - totalDepth += viewPos[2]; + const viewModelMatrix = Mat4.multiply(viewMatrix, instance.worldMatrix); + totalDepth += getTranslationZ(viewModelMatrix); count++; } } From 72fd0ea1ccfb5ff6f50335c7a18b9fe659ccd903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Tue, 5 May 2026 06:18:55 +0300 Subject: [PATCH 15/25] feat: add tests for ParticleSystemRenderer with Camera3D frustum culling support --- .../src/__tests__/particle-renderer.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 web/packages/particle-system/src/__tests__/particle-renderer.test.ts diff --git a/web/packages/particle-system/src/__tests__/particle-renderer.test.ts b/web/packages/particle-system/src/__tests__/particle-renderer.test.ts new file mode 100644 index 00000000..6aead6cc --- /dev/null +++ b/web/packages/particle-system/src/__tests__/particle-renderer.test.ts @@ -0,0 +1,54 @@ +import { Camera3D } from '@axrone/geometry'; +import { describe, expect, it } from 'vitest'; +import { ParticleSystemRenderer, BlendMode, CullMode } from '../particle-renderer'; +import { ParticleSOA } from '../particle-soa'; +import { SortMode } from '../types'; + +const createMaterial = () => ({ + id: 'particle-material', + shader: { + id: 'particle-shader', + vertexSource: '', + fragmentSource: '', + uniforms: {}, + attributes: {}, + }, + blendMode: BlendMode.Alpha, + sortMode: SortMode.Distance, + priority: 0, + cullMode: CullMode.None, + depthTest: true, + depthWrite: false, + properties: {}, +}); + +describe('ParticleSystemRenderer', () => { + it('accepts Camera3D as a frustum source for particle culling', () => { + const renderer = new ParticleSystemRenderer(8); + const particles = new ParticleSOA({ capacity: 8, autoResize: false }); + const material = createMaterial(); + const camera = Camera3D.perspective({ + id: 'camera:particles', + projection: { + kind: 'perspective', + verticalFieldOfView: Math.PI / 3, + aspectRatio: 1, + near: 0.1, + far: 50, + }, + pose: { + position: [0, 0, 0], + target: [0, 0, -1], + }, + }); + + (particles as unknown as { _initializeFreeList(): void })._initializeFreeList(); + particles.addParticle({ x: 0, y: 0, z: -3 }, { x: 0, y: 0, z: 0 }, 5, 1); + particles.addParticle({ x: 18, y: 0, z: -3 }, { x: 0, y: 0, z: 0 }, 5, 1); + + renderer.updateFrustum(camera); + renderer.createRenderBatches(particles, [material]); + + expect(renderer.getStats().renderedParticles).toBe(1); + }); +}); \ No newline at end of file From be72314395fc9faa986c1718e2a5f74c63523520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Tue, 5 May 2026 06:19:02 +0300 Subject: [PATCH 16/25] feat: add unit test for depth calculation in BatchRenderer --- .../src/__tests__/batch-renderer.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 web/packages/render-webgl2/src/__tests__/batch-renderer.test.ts diff --git a/web/packages/render-webgl2/src/__tests__/batch-renderer.test.ts b/web/packages/render-webgl2/src/__tests__/batch-renderer.test.ts new file mode 100644 index 00000000..a257fa56 --- /dev/null +++ b/web/packages/render-webgl2/src/__tests__/batch-renderer.test.ts @@ -0,0 +1,32 @@ +import { Mat4 } from '@axrone/numeric'; +import { describe, expect, it } from 'vitest'; +import { BatchRenderer } from '../batch/batch-renderer'; + +const createRenderer = () => new BatchRenderer({} as WebGL2RenderingContext, { sortByDepth: true }); + +describe('BatchRenderer', () => { + it('calculates depth from row-major translation slots', () => { + const renderer = createRenderer(); + const depth = ( + renderer as unknown as { + calculateGroupDepth(group: { + isEmpty: boolean; + instances: readonly { visible: boolean; worldMatrix: Mat4 }[]; + }, viewMatrix: Mat4): number; + } + ).calculateGroupDepth( + { + isEmpty: false, + instances: [ + { + visible: true, + worldMatrix: Mat4.translate({ x: 0, y: 0, z: -6 }), + }, + ], + }, + new Mat4() + ); + + expect(depth).toBe(-6); + }); +}); \ No newline at end of file From f857de7d44da5c0e4cda746bffb115da84135798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Tue, 5 May 2026 06:38:41 +0300 Subject: [PATCH 17/25] feat(scene-runtime): add scene mesh bounds handling to SceneMeshDefinition & implement bounding sphere calculations --- .../scene-runtime/src/scene-mesh-bounds.ts | 92 +++++++++++++++++++ web/packages/scene-runtime/src/types.ts | 2 + 2 files changed, 94 insertions(+) create mode 100644 web/packages/scene-runtime/src/scene-mesh-bounds.ts diff --git a/web/packages/scene-runtime/src/scene-mesh-bounds.ts b/web/packages/scene-runtime/src/scene-mesh-bounds.ts new file mode 100644 index 00000000..8a78f485 --- /dev/null +++ b/web/packages/scene-runtime/src/scene-mesh-bounds.ts @@ -0,0 +1,92 @@ +import type { BoundingSphere } from '@axrone/geometry'; +import type { SceneMeshDefinition } from './types'; + +const FLOAT_COMPONENT_TYPE = 0x1406; + +const cloneCenter = (center: Readonly['center']): readonly [number, number, number] => + Array.isArray(center) ? [center[0], center[1], center[2]] : [center.x, center.y, center.z]; + +export const cloneSceneMeshBounds = ( + bounds: Readonly | undefined +): BoundingSphere | undefined => { + if (!bounds) { + return undefined; + } + + return { + kind: 'sphere', + center: cloneCenter(bounds.center), + radius: bounds.radius, + }; +}; + +export const resolveSceneMeshBounds = ( + definition: Readonly +): BoundingSphere | undefined => { + if (definition.bounds?.kind === 'sphere') { + return cloneSceneMeshBounds(definition.bounds); + } + + const positionAttribute = definition.attributes.find( + (attribute) => + attribute.semantic === 'position' && + attribute.componentCount >= 3 && + (attribute.type === undefined || attribute.type === FLOAT_COMPONENT_TYPE) + ); + + if (!positionAttribute) { + return undefined; + } + + const source = definition.vertices; + const byteOffset = ArrayBuffer.isView(source) ? source.byteOffset : 0; + const byteLength = ArrayBuffer.isView(source) ? source.byteLength : source.byteLength; + const vertexStride = Math.max(positionAttribute.stride, positionAttribute.componentCount * 4); + const vertexCount = definition.vertexCount ?? Math.floor(byteLength / vertexStride); + + if (vertexCount <= 0 || positionAttribute.offset + 12 > vertexStride) { + return undefined; + } + + const dataView = new DataView( + ArrayBuffer.isView(source) ? source.buffer : source, + byteOffset, + byteLength + ); + + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let minZ = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + let maxZ = Number.NEGATIVE_INFINITY; + + for (let vertexIndex = 0; vertexIndex < vertexCount; vertexIndex += 1) { + const positionOffset = vertexIndex * vertexStride + positionAttribute.offset; + if (positionOffset + 12 > byteLength) { + return undefined; + } + + const x = dataView.getFloat32(positionOffset, true); + const y = dataView.getFloat32(positionOffset + 4, true); + const z = dataView.getFloat32(positionOffset + 8, true); + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + minZ = Math.min(minZ, z); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + maxZ = Math.max(maxZ, z); + } + + const centerX = (minX + maxX) * 0.5; + const centerY = (minY + maxY) * 0.5; + const centerZ = (minZ + maxZ) * 0.5; + const radius = Math.hypot(maxX - centerX, maxY - centerY, maxZ - centerZ); + + return { + kind: 'sphere', + center: [centerX, centerY, centerZ], + radius, + }; +}; \ No newline at end of file diff --git a/web/packages/scene-runtime/src/types.ts b/web/packages/scene-runtime/src/types.ts index e92b8a40..ae8a36ab 100644 --- a/web/packages/scene-runtime/src/types.ts +++ b/web/packages/scene-runtime/src/types.ts @@ -1,3 +1,4 @@ +import type { BoundingSphere } from '@axrone/geometry'; import type { Mat4, Quat, Vec2, Vec3, Vec4 } from '@axrone/numeric'; import type { Actor, ActorConfig } from '@axrone/ecs-runtime'; import type { World } from '@axrone/ecs-runtime'; @@ -79,6 +80,7 @@ export interface SceneMeshDefinition { readonly id: string; readonly vertices: BufferSource; readonly attributes: readonly SceneVertexAttribute[]; + readonly bounds?: BoundingSphere; readonly morphTargets?: readonly SceneMorphTargetDefinition[]; readonly indices?: Uint8Array | Uint16Array | Uint32Array; readonly vertexCount?: number; From 289ec8049a59d6330210be3ddec8da08902e22c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Tue, 5 May 2026 06:40:22 +0300 Subject: [PATCH 18/25] feat(scene-runtime): integrate scene mesh bounds resolution and enhance rendering with camera frustum support --- .../scene-runtime/src/mesh-registry.ts | 6 ++- .../src/render-item-collector.ts | 54 +++++++++++++++++++ .../src/scene-geometry-mesh-builder.ts | 6 ++- .../scene-runtime/src/scene-render-runtime.ts | 7 +++ .../scene-runtime/src/serialization.ts | 31 +++++++++++ 5 files changed, 102 insertions(+), 2 deletions(-) diff --git a/web/packages/scene-runtime/src/mesh-registry.ts b/web/packages/scene-runtime/src/mesh-registry.ts index c3fc9bdd..ce168cf0 100644 --- a/web/packages/scene-runtime/src/mesh-registry.ts +++ b/web/packages/scene-runtime/src/mesh-registry.ts @@ -1,4 +1,5 @@ import { cloneMeshDefinition } from './serialization'; +import { resolveSceneMeshBounds } from './scene-mesh-bounds'; import type { SceneMeshDefinition, SceneMeshHandle, @@ -47,8 +48,11 @@ export class SceneMeshRegistry { resource: SceneMeshResource ): SceneMeshRegistrationResult { const previous = this._resources.get(resource.id) ?? null; + const bounds = resolveSceneMeshBounds(definition); + const normalizedDefinition = + bounds && !definition.bounds ? { ...definition, bounds } : definition; this._resources.set(resource.id, resource); - this._definitions.set(resource.id, cloneSceneMeshDefinition(definition)); + this._definitions.set(resource.id, cloneSceneMeshDefinition(normalizedDefinition)); return { handle: toHandle(resource), diff --git a/web/packages/scene-runtime/src/render-item-collector.ts b/web/packages/scene-runtime/src/render-item-collector.ts index ee88973a..a5de6de0 100644 --- a/web/packages/scene-runtime/src/render-item-collector.ts +++ b/web/packages/scene-runtime/src/render-item-collector.ts @@ -1,5 +1,7 @@ +import type { BoundingSphere, CameraFrustum } from '@axrone/geometry'; import type { Actor } from '@axrone/ecs-runtime'; import { Transform } from '@axrone/ecs-runtime'; +import { Vec3 } from '@axrone/numeric'; import { MeshRenderer } from './components/mesh-renderer'; export interface SceneRenderItem { @@ -13,9 +15,20 @@ export interface SceneRenderItemSortOptions { readonly y: number; readonly z: number; }; + readonly cameraFrustum?: Readonly; + readonly resolveBounds?: (renderer: MeshRenderer) => Readonly | null | undefined; readonly isBlended?: (renderer: MeshRenderer) => boolean; } +const readCenterX = (bounds: Readonly): number => + Array.isArray(bounds.center) ? bounds.center[0] : bounds.center.x; + +const readCenterY = (bounds: Readonly): number => + Array.isArray(bounds.center) ? bounds.center[1] : bounds.center.y; + +const readCenterZ = (bounds: Readonly): number => + Array.isArray(bounds.center) ? bounds.center[2] : bounds.center.z; + const distanceSquaredToCamera = ( transform: Transform, cameraPosition: NonNullable @@ -29,6 +42,13 @@ const distanceSquaredToCamera = ( export class SceneRenderItemCollector { private readonly _items: SceneRenderItem[] = []; + private readonly _cullingSphereCenter = new Vec3(); + private readonly _cullingSphereOffset = new Vec3(); + private readonly _cullingSphere: BoundingSphere = { + kind: 'sphere', + center: this._cullingSphereCenter, + radius: 0, + }; collect( actors: readonly Actor[], @@ -55,6 +75,18 @@ export class SceneRenderItemCollector { continue; } + if ( + sortOptions.cameraFrustum && + sortOptions.resolveBounds && + !this._intersectsCameraFrustum( + transform, + sortOptions.resolveBounds(renderer), + sortOptions.cameraFrustum + ) + ) { + continue; + } + const item = this._items[count] ?? { transform, renderer }; item.transform = transform; item.renderer = renderer; @@ -86,4 +118,26 @@ export class SceneRenderItemCollector { }); return this._items; } + + private _intersectsCameraFrustum( + transform: Transform, + bounds: Readonly | null | undefined, + frustum: Readonly + ): boolean { + if (!bounds) { + return true; + } + + const worldScale = transform.worldScale; + this._cullingSphereOffset.x = readCenterX(bounds) * worldScale.x; + this._cullingSphereOffset.y = readCenterY(bounds) * worldScale.y; + this._cullingSphereOffset.z = readCenterZ(bounds) * worldScale.z; + transform.worldRotation.rotateVector(this._cullingSphereOffset, this._cullingSphereOffset); + Vec3.add(transform.worldPosition, this._cullingSphereOffset, this._cullingSphereCenter); + this._cullingSphere.radius = + bounds.radius * + Math.max(Math.abs(worldScale.x), Math.abs(worldScale.y), Math.abs(worldScale.z)); + + return frustum.intersectsSphere(this._cullingSphere); + } } diff --git a/web/packages/scene-runtime/src/scene-geometry-mesh-builder.ts b/web/packages/scene-runtime/src/scene-geometry-mesh-builder.ts index dc265d47..a03cf77f 100644 --- a/web/packages/scene-runtime/src/scene-geometry-mesh-builder.ts +++ b/web/packages/scene-runtime/src/scene-geometry-mesh-builder.ts @@ -1,4 +1,5 @@ import type { IGeometryBuffers } from '@axrone/geometry'; +import { resolveSceneMeshBounds } from './scene-mesh-bounds'; import type { SceneMeshDefinition, SceneMeshSemantic } from './types'; const mapGeometryAttribute = (name: string): SceneMeshSemantic | null => { @@ -74,7 +75,7 @@ export class SceneGeometryMeshBuilder { } } - return { + const definition: SceneMeshDefinition = { id, vertices: vertexBytes, indices: indexArray, @@ -82,5 +83,8 @@ export class SceneGeometryMeshBuilder { topology: geometryBuffers.layout.primitiveType, attributes, }; + + const bounds = resolveSceneMeshBounds(definition); + return bounds ? { ...definition, bounds } : definition; } } diff --git a/web/packages/scene-runtime/src/scene-render-runtime.ts b/web/packages/scene-runtime/src/scene-render-runtime.ts index 4797f267..1771f50c 100644 --- a/web/packages/scene-runtime/src/scene-render-runtime.ts +++ b/web/packages/scene-runtime/src/scene-render-runtime.ts @@ -183,6 +183,13 @@ export class SceneRenderRuntime { renderPass.rendererPassId, { cameraPosition: cameraFrame.position, + cameraFrustum: cameraFrame.camera3D.frustum, + resolveBounds: (renderer) => { + const meshId = renderer.meshId; + return meshId + ? this._options.resources.meshes.getDefinition(meshId)?.bounds + : undefined; + }, isBlended: (renderer) => this._isBlendedRenderer(renderer, renderPass), } ); diff --git a/web/packages/scene-runtime/src/serialization.ts b/web/packages/scene-runtime/src/serialization.ts index e21541a0..b65be647 100644 --- a/web/packages/scene-runtime/src/serialization.ts +++ b/web/packages/scene-runtime/src/serialization.ts @@ -1,4 +1,5 @@ import { Mat4, Quat, Vec2, Vec3, Vec4 } from '@axrone/numeric'; +import { cloneSceneMeshBounds } from './scene-mesh-bounds'; import type { SceneMorphTargetDefinition, SceneMeshDefinition, @@ -164,6 +165,7 @@ export const cloneMeshDefinition = (definition: SceneMeshDefinition): SceneMeshD ...definition, vertices, indices, + ...(definition.bounds ? { bounds: cloneSceneMeshBounds(definition.bounds) } : {}), attributes: definition.attributes.map((attribute) => ({ ...attribute })), ...(definition.morphTargets ? { morphTargets: cloneMorphTargets(definition.morphTargets) } @@ -203,6 +205,19 @@ export const serializeMeshDefinition = (definition: SceneMeshDefinition): SceneS })), })) : null, + bounds: definition.bounds + ? { + kind: 'sphere', + center: Array.isArray(definition.bounds.center) + ? [...definition.bounds.center] + : [ + definition.bounds.center.x, + definition.bounds.center.y, + definition.bounds.center.z, + ], + radius: definition.bounds.radius, + } + : null, vertexCount: definition.vertexCount ?? null, topology: definition.topology ?? 'triangles', usage: definition.usage ?? null, @@ -249,6 +264,22 @@ export const deserializeMeshDefinition = (value: SceneSerializedValue): SceneMes id: String(objectValue.id), vertices, indices, + bounds: + objectValue.bounds !== null && + !Array.isArray(objectValue.bounds) && + typeof objectValue.bounds === 'object' && + objectValue.bounds.kind === 'sphere' && + Array.isArray(objectValue.bounds.center) + ? { + kind: 'sphere' as const, + center: [ + Number(objectValue.bounds.center[0]), + Number(objectValue.bounds.center[1]), + Number(objectValue.bounds.center[2]), + ] as const, + radius: Number(objectValue.bounds.radius), + } + : undefined, morphTargets: Array.isArray(objectValue.morphTargets) ? Object.freeze( objectValue.morphTargets.map((target: SceneSerializedValue) => { From 585458172619ddefb5698d75e127b934656053ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Tue, 5 May 2026 06:40:48 +0300 Subject: [PATCH 19/25] feat(scene-runtime): add test for culling mesh renderers outside camera frustum --- .../__tests__/render-item-collector.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/web/packages/scene-runtime/src/__tests__/render-item-collector.test.ts b/web/packages/scene-runtime/src/__tests__/render-item-collector.test.ts index 548de050..cb6af776 100644 --- a/web/packages/scene-runtime/src/__tests__/render-item-collector.test.ts +++ b/web/packages/scene-runtime/src/__tests__/render-item-collector.test.ts @@ -1,3 +1,4 @@ +import { Camera3D } from '@axrone/geometry'; import { describe, expect, it } from 'vitest'; import { Vec3 } from '@axrone/numeric'; import type { Actor } from '@axrone/ecs-runtime'; @@ -20,6 +21,61 @@ const createMockActor = (transform: Transform, renderer: MeshRenderer): Actor => } as unknown as Actor); describe('SceneRenderItemCollector', () => { + it('culls mesh renderers outside the active camera frustum before sorting', () => { + const insideTransform = new Transform(); + insideTransform.position = new Vec3(0, 0, -4); + const insideRenderer = new MeshRenderer({ + meshId: 'mesh', + materialId: 'inside', + renderOrder: 0, + passId: 'main', + }); + + const outsideTransform = new Transform(); + outsideTransform.position = new Vec3(25, 0, -4); + const outsideRenderer = new MeshRenderer({ + meshId: 'mesh', + materialId: 'outside', + renderOrder: 0, + passId: 'main', + }); + + const camera = Camera3D.perspective({ + id: 'camera:collector', + projection: { + kind: 'perspective', + verticalFieldOfView: Math.PI / 3, + aspectRatio: 1, + near: 0.1, + far: 100, + }, + pose: { + position: [0, 0, 0], + target: [0, 0, -1], + }, + }); + + const collector = new SceneRenderItemCollector(); + const renderItems = collector.collect( + [ + createMockActor(insideTransform, insideRenderer), + createMockActor(outsideTransform, outsideRenderer), + ], + 'main', + { + cameraPosition: new Vec3(0, 0, 0), + cameraFrustum: camera.frustum, + resolveBounds: () => ({ + kind: 'sphere', + center: [0, 0, 0], + radius: 1, + }), + } + ); + + expect(renderItems.map((item) => item.renderer.materialId)).toEqual(['inside']); + }); + it('draws blended renderers back-to-front after opaque renderers at the same render order', () => { const opaqueTransform = new Transform(); opaqueTransform.position = new Vec3(0, 0, 1); From 3f05b56b37e4bc2e5f3366fff3e3751f6bd77dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Tue, 5 May 2026 06:41:18 +0300 Subject: [PATCH 20/25] feat(tests): add bounds validation for mesh definitions --- web/packages/scene-3d/src/__tests__/scene-asset-runtime.test.ts | 2 ++ .../scene-3d/src/__tests__/scene-geometry-mesh-builder.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/web/packages/scene-3d/src/__tests__/scene-asset-runtime.test.ts b/web/packages/scene-3d/src/__tests__/scene-asset-runtime.test.ts index 5190c0e1..e298ce6e 100644 --- a/web/packages/scene-3d/src/__tests__/scene-asset-runtime.test.ts +++ b/web/packages/scene-3d/src/__tests__/scene-asset-runtime.test.ts @@ -35,6 +35,8 @@ describe('SceneAssetRuntime', () => { }); expect(releaseBaseMesh).toHaveBeenCalledWith('mesh'); + expect(runtime.resources.meshes.getDefinition('mesh')?.bounds?.kind).toBe('sphere'); + expect(runtime.resources.meshes.getDefinition('mesh')?.bounds?.radius).toBeGreaterThan(0.7); }); it('clears runtime-owned GPU resources and render passes through one boundary', async () => { diff --git a/web/packages/scene-3d/src/__tests__/scene-geometry-mesh-builder.test.ts b/web/packages/scene-3d/src/__tests__/scene-geometry-mesh-builder.test.ts index d980b563..a4f6c6e0 100644 --- a/web/packages/scene-3d/src/__tests__/scene-geometry-mesh-builder.test.ts +++ b/web/packages/scene-3d/src/__tests__/scene-geometry-mesh-builder.test.ts @@ -20,6 +20,8 @@ describe('SceneGeometryMeshBuilder', () => { expect(definition.vertexCount).toBeGreaterThan(0); expect(definition.vertices).toBeInstanceOf(Float32Array); expect(definition.indices).toBeInstanceOf(Uint16Array); + expect(definition.bounds?.kind).toBe('sphere'); + expect(definition.bounds?.radius).toBeGreaterThan(0.7); expect(definition.attributes.map((attribute) => attribute.semantic)).toEqual([ 'position', 'normal', From 5e14c6ee26562059e4158f3e1cbfa8b821a057c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Tue, 5 May 2026 06:46:40 +0300 Subject: [PATCH 21/25] feat(scene-runtime): enhance mesh definition adaptation with bounds handling --- .../src/scene-definition-adapter.ts | 28 ++++++++++++++++++- .../src/scene-snapshot-adapter.ts | 2 +- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/web/packages/scene-runtime-gltf/src/scene-definition-adapter.ts b/web/packages/scene-runtime-gltf/src/scene-definition-adapter.ts index d711e106..765223fe 100644 --- a/web/packages/scene-runtime-gltf/src/scene-definition-adapter.ts +++ b/web/packages/scene-runtime-gltf/src/scene-definition-adapter.ts @@ -2,6 +2,7 @@ import type { GltfActorSnapshot, GltfComponentSnapshot, GltfMaterialDefinition, + GltfMeshBounds, GltfMeshDefinition, GltfPrefabDefinition, GltfSamplerDefinition, @@ -37,12 +38,37 @@ export const adaptGltfPrefabDefinitionToScene = ( actors: definition.actors.map(adaptGltfActorSnapshotToScene), }); +const adaptGltfMeshBoundsToScene = ( + bounds: Readonly | undefined +): SceneMeshDefinition['bounds'] | undefined => { + if (!bounds) { + return undefined; + } + + const centerX = (bounds.min[0] + bounds.max[0]) * 0.5; + const centerY = (bounds.min[1] + bounds.max[1]) * 0.5; + const centerZ = (bounds.min[2] + bounds.max[2]) * 0.5; + const radius = Math.hypot( + bounds.max[0] - centerX, + bounds.max[1] - centerY, + bounds.max[2] - centerZ + ); + + return { + kind: 'sphere', + center: [centerX, centerY, centerZ], + radius, + }; +}; + export const adaptGltfMeshDefinitionToScene = ( definition: GltfMeshDefinition, - id: string + id: string, + bounds?: Readonly ): SceneMeshDefinition => ({ ...definition, id, + ...(adaptGltfMeshBoundsToScene(bounds) ? { bounds: adaptGltfMeshBoundsToScene(bounds) } : {}), attributes: [...definition.attributes], morphTargets: definition.morphTargets ? definition.morphTargets.map((target) => ({ diff --git a/web/packages/scene-runtime-gltf/src/scene-snapshot-adapter.ts b/web/packages/scene-runtime-gltf/src/scene-snapshot-adapter.ts index a49eb0e2..784a676e 100644 --- a/web/packages/scene-runtime-gltf/src/scene-snapshot-adapter.ts +++ b/web/packages/scene-runtime-gltf/src/scene-snapshot-adapter.ts @@ -101,7 +101,7 @@ export const createGltfSceneSnapshot = ( for (const meshKey of prefab.data.meshKeys) { const mesh = database.require({ key: meshKey, kind: 'gltf.mesh' }); - meshes.push(adaptGltfMeshDefinitionToScene(mesh.data.definition, mesh.key)); + meshes.push(adaptGltfMeshDefinitionToScene(mesh.data.definition, mesh.key, mesh.data.bounds)); } return { From e9fa3cd70f76d3ac1f874a7570f2650ac8de79fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Tue, 5 May 2026 06:46:48 +0300 Subject: [PATCH 22/25] feat(tests): add test for converting glTF mesh bounds to scene bounding spheres --- .../scene-definition-adapter.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 web/packages/scene-runtime-gltf/src/__tests__/scene-definition-adapter.test.ts diff --git a/web/packages/scene-runtime-gltf/src/__tests__/scene-definition-adapter.test.ts b/web/packages/scene-runtime-gltf/src/__tests__/scene-definition-adapter.test.ts new file mode 100644 index 00000000..e45356ae --- /dev/null +++ b/web/packages/scene-runtime-gltf/src/__tests__/scene-definition-adapter.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import type { GltfMeshDefinition } from '@axrone/asset-gltf'; +import { adaptGltfMeshDefinitionToScene } from '../scene-definition-adapter'; + +describe('scene-definition-adapter', () => { + it('converts glTF mesh min-max bounds into scene bounding spheres', () => { + const meshDefinition: GltfMeshDefinition = { + id: 'mesh/source', + vertices: new Float32Array([ + 0, 0, 0, + 1, 0, 0, + 0, 1, 0, + ]), + attributes: [ + { + semantic: 'position', + componentCount: 3, + offset: 0, + stride: 12, + }, + ], + indices: new Uint16Array([0, 1, 2]), + }; + + const adapted = adaptGltfMeshDefinitionToScene(meshDefinition, 'mesh/runtime', { + min: [0, 0, 0], + max: [1, 1, 0], + }); + + expect(adapted.id).toBe('mesh/runtime'); + expect(adapted.bounds).toEqual({ + kind: 'sphere', + center: [0.5, 0.5, 0], + radius: Math.sqrt(0.5), + }); + }); +}); \ No newline at end of file From 6599cb62a9f3544fdfb657567a137d0eb07e2b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Tue, 5 May 2026 06:49:24 +0300 Subject: [PATCH 23/25] feat(scene-runtime): add camera frustum culling to sprite render item collection --- .../src/sprite-render-item-collector.ts | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/web/packages/scene-runtime/src/sprite-render-item-collector.ts b/web/packages/scene-runtime/src/sprite-render-item-collector.ts index 34539f7c..f7824df0 100644 --- a/web/packages/scene-runtime/src/sprite-render-item-collector.ts +++ b/web/packages/scene-runtime/src/sprite-render-item-collector.ts @@ -1,5 +1,7 @@ +import type { CameraFrustum } from '@axrone/geometry'; import type { Actor } from '@axrone/ecs-runtime'; import { Transform } from '@axrone/ecs-runtime'; +import { Vec3 } from '@axrone/numeric'; import { SpriteRenderer } from './components/sprite-renderer'; export interface SceneSpriteRenderItem { @@ -10,10 +12,25 @@ export interface SceneSpriteRenderItem { depth: number; } +export interface SceneSpriteRenderItemCollectOptions { + readonly cameraFrustum?: Readonly; +} + export class SceneSpriteRenderItemCollector { private readonly _items: SceneSpriteRenderItem[] = []; + private readonly _cullingSphereCenter = new Vec3(); + private readonly _cullingSphereOffset = new Vec3(); + private readonly _cullingSphere = { + kind: 'sphere' as const, + center: this._cullingSphereCenter, + radius: 0, + }; - collect(actors: readonly Actor[], passId: string): readonly SceneSpriteRenderItem[] { + collect( + actors: readonly Actor[], + passId: string, + options: SceneSpriteRenderItemCollectOptions = {} + ): readonly SceneSpriteRenderItem[] { let count = 0; for (const actor of actors) { @@ -35,6 +52,10 @@ export class SceneSpriteRenderItemCollector { continue; } + if (options.cameraFrustum && !this._intersectsCameraFrustum(transform, renderer, options.cameraFrustum)) { + continue; + } + const item = this._items[count] ?? { actor, transform, @@ -72,4 +93,25 @@ export class SceneSpriteRenderItemCollector { }); return this._items; } + + private _intersectsCameraFrustum( + transform: Transform, + renderer: SpriteRenderer, + frustum: Readonly + ): boolean { + const localCenterX = (0.5 - renderer.anchor.x) * renderer.size.x; + const localCenterY = (0.5 - renderer.anchor.y) * renderer.size.y; + const localRadius = 0.5 * Math.hypot(renderer.size.x, renderer.size.y); + const worldScale = transform.worldScale; + + this._cullingSphereOffset.x = localCenterX * worldScale.x; + this._cullingSphereOffset.y = localCenterY * worldScale.y; + this._cullingSphereOffset.z = 0; + transform.worldRotation.rotateVector(this._cullingSphereOffset, this._cullingSphereOffset); + Vec3.add(transform.worldPosition, this._cullingSphereOffset, this._cullingSphereCenter); + this._cullingSphere.radius = + localRadius * Math.max(Math.abs(worldScale.x), Math.abs(worldScale.y), Math.abs(worldScale.z)); + + return frustum.intersectsSphere(this._cullingSphere); + } } \ No newline at end of file From 3a499332435ece3f50f0db0c6bcf37795686125d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Tue, 5 May 2026 06:49:32 +0300 Subject: [PATCH 24/25] feat(scene-runtime): pass camera frustum to sprite item collector during rendering --- web/packages/scene-runtime/src/sprite-batch-runtime.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/packages/scene-runtime/src/sprite-batch-runtime.ts b/web/packages/scene-runtime/src/sprite-batch-runtime.ts index 5797f520..bc57cfc7 100644 --- a/web/packages/scene-runtime/src/sprite-batch-runtime.ts +++ b/web/packages/scene-runtime/src/sprite-batch-runtime.ts @@ -159,7 +159,9 @@ export class SceneSpriteBatchRuntime { } render(params: SceneSpriteBatchRuntimeRenderParams): void { - const items = this._collector.collect(params.actors, params.renderPass.rendererPassId); + const items = this._collector.collect(params.actors, params.renderPass.rendererPassId, { + cameraFrustum: params.cameraFrame.camera3D.frustum, + }); if (items.length === 0) { return; } From 64ec8a8fa5ff7425f69c95b07aac5b494a818624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Tue, 5 May 2026 06:49:41 +0300 Subject: [PATCH 25/25] feat(tests): add unit test for culling sprites outside camera frustum in SceneSpriteRenderItemCollector --- .../sprite-render-item-collector.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 web/packages/scene-runtime/src/__tests__/sprite-render-item-collector.test.ts diff --git a/web/packages/scene-runtime/src/__tests__/sprite-render-item-collector.test.ts b/web/packages/scene-runtime/src/__tests__/sprite-render-item-collector.test.ts new file mode 100644 index 00000000..ff1ce099 --- /dev/null +++ b/web/packages/scene-runtime/src/__tests__/sprite-render-item-collector.test.ts @@ -0,0 +1,68 @@ +import { Camera3D } from '@axrone/geometry'; +import type { Actor } from '@axrone/ecs-runtime'; +import { Transform } from '@axrone/ecs-runtime'; +import { Vec3 } from '@axrone/numeric'; +import { describe, expect, it } from 'vitest'; +import { SpriteRenderer } from '../components/sprite-renderer'; +import { SceneSpriteRenderItemCollector } from '../sprite-render-item-collector'; + +const createMockActor = (transform: Transform, renderer: SpriteRenderer): Actor => + ({ + active: true, + getComponent(componentType: unknown) { + if (componentType === Transform) { + return transform; + } + if (componentType === SpriteRenderer) { + return renderer; + } + return undefined; + }, + } as unknown as Actor); + +describe('SceneSpriteRenderItemCollector', () => { + it('culls sprites outside the active camera frustum before batching', () => { + const insideTransform = new Transform(); + insideTransform.position = new Vec3(0, 0, -3); + const insideRenderer = new SpriteRenderer({ + textureId: 'tex', + passId: 'main', + size: [1, 1], + }); + + const outsideTransform = new Transform(); + outsideTransform.position = new Vec3(20, 0, -3); + const outsideRenderer = new SpriteRenderer({ + textureId: 'tex', + passId: 'main', + size: [1, 1], + }); + + const camera = Camera3D.perspective({ + id: 'camera:sprite-collector', + projection: { + kind: 'perspective', + verticalFieldOfView: Math.PI / 3, + aspectRatio: 1, + near: 0.1, + far: 100, + }, + pose: { + position: [0, 0, 0], + target: [0, 0, -1], + }, + }); + + const collector = new SceneSpriteRenderItemCollector(); + const items = collector.collect( + [ + createMockActor(insideTransform, insideRenderer), + createMockActor(outsideTransform, outsideRenderer), + ], + 'main', + { cameraFrustum: camera.frustum } + ); + + expect(items.map((item) => item.renderer)).toEqual([insideRenderer]); + }); +}); \ No newline at end of file