From 5e8744f5b2359b50247b3dbc6cadaf6cd1914c1f Mon Sep 17 00:00:00 2001 From: Exoridus Date: Tue, 23 Jun 2026 12:54:20 +0200 Subject: [PATCH] =?UTF-8?q?perf:=20v0.15=20Welle=200=20=E2=80=94=20Quick-W?= =?UTF-8?q?ins=20(Hot-Path-Allokationen=20+=20Typl=C3=B6cher)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allokations-freie Hot-Paths, geschlossene Typlöcher, Doku-Sichtbarkeit. Verhaltens-aequivalent; keine oeffentlichen Signaturen entfernt/umbenannt. - perf(math): Polygon.project() inline ohne clone/map/spread (SAT-Hot-Path) - perf(rendering): Container.contains() for-Schleife statt .some-Closure - perf(core): _invalidateBoundsCascade am dirty-Vorfahren kurzschliessen - perf(input): InputManager pointers mit Map statt Record (kein Object.values/Frame) - perf(particles): WebGPU-Uniform direkte Index-Writes statt 44-Element-Literal - refactor(types): Loadable/AssetInput => unknown; gratuitoesen View.updateId-Cast + dadurch redundante Folge-Casts in Loader/SceneNode entfernt - docs(math): Collision-Bezeichner klargestellt; Time.temp/Polygon.temp @internal Tests: Polygon.project()-Coverage + Cascade-Kurzschluss-Korrektheitstest. API-Docs (time/polygon.mdx) fuer die @internal-Marker regeneriert. --- .../src/renderers/WebGpuParticleRenderer.ts | 104 +++++++++--------- site/src/content/api/polygon.mdx | 3 +- site/src/content/api/time.mdx | 3 +- src/core/SceneNode.ts | 18 ++- src/core/Time.ts | 5 + src/input/InputManager.ts | 34 +++--- src/math/Polygon.ts | 25 ++++- src/math/index.ts | 4 + src/rendering/Container.ts | 10 +- .../webgl2/WebGl2RepeatingSpriteRenderer.ts | 4 +- src/resources/AssetDefinitions.ts | 3 +- src/resources/Loader.ts | 6 +- test/core/scene-node-cache.test.ts | 42 +++++++ test/input/pointer-channels.test.ts | 4 +- test/math/polygon.test.ts | 47 ++++++++ 15 files changed, 227 insertions(+), 85 deletions(-) diff --git a/packages/exojs-particles/src/renderers/WebGpuParticleRenderer.ts b/packages/exojs-particles/src/renderers/WebGpuParticleRenderer.ts index ec1cd11b..66e1bf7d 100644 --- a/packages/exojs-particles/src/renderers/WebGpuParticleRenderer.ts +++ b/packages/exojs-particles/src/renderers/WebGpuParticleRenderer.ts @@ -393,55 +393,61 @@ export class WebGpuParticleRenderer extends AbstractWebGpuRenderer>> 16) & 0xffff) / 0xffff; - this._uniformData.set([ - projection[0], - projection[1], - 0, - 0, - projection[3], - projection[4], - 0, - 0, - 0, - 0, - 1, - 0, - projection[6], - projection[7], - 0, - projection[8], - - transform[0], - transform[1], - 0, - 0, - transform[3], - transform[4], - 0, - 0, - 0, - 0, - 1, - 0, - transform[6], - transform[7], - 0, - transform[8], - - shouldPremultiplySample ? 1 : 0, - 0, - 0, - 0, - - quadMinX, - quadMinY, - quadSizeX, - quadSizeY, - uvMinX, - uvMinY, - uvMaxX, - uvMaxY, - ]); + const u = this._uniformData; + + // projection mat4 (col-major, padded to 4×4) + u[0] = projection[0]; + u[1] = projection[1]; + u[2] = 0; + u[3] = 0; + u[4] = projection[3]; + u[5] = projection[4]; + u[6] = 0; + u[7] = 0; + u[8] = 0; + u[9] = 0; + u[10] = 1; + u[11] = 0; + u[12] = projection[6]; + u[13] = projection[7]; + u[14] = 0; + u[15] = projection[8]; + + // transform mat4 (col-major, padded to 4×4) + u[16] = transform[0]; + u[17] = transform[1]; + u[18] = 0; + u[19] = 0; + u[20] = transform[3]; + u[21] = transform[4]; + u[22] = 0; + u[23] = 0; + u[24] = 0; + u[25] = 0; + u[26] = 1; + u[27] = 0; + u[28] = transform[6]; + u[29] = transform[7]; + u[30] = 0; + u[31] = transform[8]; + + // flags vec4 + u[32] = shouldPremultiplySample ? 1 : 0; + u[33] = 0; + u[34] = 0; + u[35] = 0; + + // localBounds vec4 + u[36] = quadMinX; + u[37] = quadMinY; + u[38] = quadSizeX; + u[39] = quadSizeY; + + // uvBounds vec4 + u[40] = uvMinX; + u[41] = uvMinY; + u[42] = uvMaxX; + u[43] = uvMaxY; } private _writeInstanceData(system: ParticleSystem): number { diff --git a/site/src/content/api/polygon.mdx b/site/src/content/api/polygon.mdx index ab26dc87..15da3f43 100644 --- a/site/src/content/api/polygon.mdx +++ b/site/src/content/api/polygon.mdx @@ -5,7 +5,7 @@ symbol: "Polygon" kind: "class" subsystem: "math" importPath: "@codexo/exojs" -memberCount: 21 +memberCount: 20 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Source"] sourcePath: "src/math/Polygon.ts" @@ -52,7 +52,6 @@ cached lazily and invalidated on any positional or point mutation. - `position: Vector` - `x: number` - `y: number` -- `temp: Polygon` ## Source diff --git a/site/src/content/api/time.mdx b/site/src/content/api/time.mdx index 82bd4c56..b030fc21 100644 --- a/site/src/content/api/time.mdx +++ b/site/src/content/api/time.mdx @@ -5,7 +5,7 @@ symbol: "Time" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 35 +memberCount: 34 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Source"] sourcePath: "src/core/Time.ts" @@ -68,7 +68,6 @@ reference across calls. - `milliseconds: number` - `minutes: number` - `seconds: number` -- `temp: Time` ## Source diff --git a/src/core/SceneNode.ts b/src/core/SceneNode.ts index f8b930ba..3b18952c 100644 --- a/src/core/SceneNode.ts +++ b/src/core/SceneNode.ts @@ -555,15 +555,21 @@ export class SceneNode implements Collidable, ObservableVectorOwner { /** Mark own Bounds dirty AND propagate up to Container ancestors' Bounds. */ public _invalidateBoundsCascade(): void { + // Mark own bounds + notify interaction for THIS node unconditionally — + // the manager filters to tracked interactive nodes so this call is O(1) + // for the common case (non-interactive node — fast Set.has miss). this.flags.push(SceneNodeTransformFlags.BoundsRect); - - // Notify the InteractionManager so it can mark the quadtree entry stale. - // The manager filters to only tracked interactive nodes so this call is - // O(1) for the common case (non-interactive node — fast Set.has miss). this._stage?.interaction._notifyBoundsInvalidated(this as unknown as RenderNode); - if (this._parentNode) { - this._parentNode._invalidateBoundsCascade(); + // Walk up, but stop at the first ancestor already flagged dirty: if a parent + // is already BoundsRect-dirty, its whole ancestor chain was already cascaded, + // so re-walking it is redundant work. + let ancestor = this._parentNode; + + while (ancestor !== null && !ancestor.flags.has(SceneNodeTransformFlags.BoundsRect)) { + ancestor.flags.push(SceneNodeTransformFlags.BoundsRect); + ancestor._stage?.interaction._notifyBoundsInvalidated(ancestor); + ancestor = ancestor.parent; } } diff --git a/src/core/Time.ts b/src/core/Time.ts index 0e0a5b79..1f93a593 100644 --- a/src/core/Time.ts +++ b/src/core/Time.ts @@ -183,6 +183,11 @@ export class Time implements Cloneable { public static readonly oneMinute = new Time(1, Time.minutes); public static readonly oneHour = new Time(1, Time.hours); + /** + * Shared scratch {@link Time} instance for intermediate calculations. Never + * retain the reference across frames or async boundaries. + * @internal + */ public static get temp(): Time { if (temp === null) { temp = new Time(); diff --git a/src/input/InputManager.ts b/src/input/InputManager.ts index af77827e..097bdc85 100644 --- a/src/input/InputManager.ts +++ b/src/input/InputManager.ts @@ -63,7 +63,7 @@ export class InputManager implements System { private readonly _app: Application; private readonly canvas: HTMLCanvasElement; private readonly channels: Float32Array = new Float32Array(ChannelSize.Container); - private readonly pointers: Record = {}; + private readonly pointers = new Map(); private readonly _gamepads: readonly [Gamepad, Gamepad, Gamepad, Gamepad]; private readonly gamepadsByBrowserIndex = new Map(); private readonly bindings: Set = new Set(); @@ -194,13 +194,13 @@ export class InputManager implements System { * no active pointer is present. Used by debug layers to show cursor info. */ public getPrimaryPointerPosition(): { x: number; y: number } | null { - for (const pointer of Object.values(this.pointers)) { + for (const pointer of this.pointers.values()) { if (pointer.isPrimary && pointer.currentState !== PointerState.Cancelled) { return { x: pointer.x, y: pointer.y }; } } - for (const pointer of Object.values(this.pointers)) { + for (const pointer of this.pointers.values()) { if (pointer.currentState !== PointerState.Cancelled) { return { x: pointer.x, y: pointer.y }; } @@ -210,7 +210,13 @@ export class InputManager implements System { } public get pointersInCanvas(): boolean { - return Object.values(this.pointers).some(pointer => pointer.currentState !== PointerState.OutsideCanvas && pointer.currentState !== PointerState.Cancelled); + for (const pointer of this.pointers.values()) { + if (pointer.currentState !== PointerState.OutsideCanvas && pointer.currentState !== PointerState.Cancelled) { + return true; + } + } + + return false; } public get canvasFocused(): boolean { @@ -345,10 +351,12 @@ export class InputManager implements System { this.removeEventListeners(); this.gestureRecognizer.destroy(); - for (const pointer of Object.values(this.pointers)) { + for (const pointer of this.pointers.values()) { pointer.destroy(); } + this.pointers.clear(); + for (const pad of this._gamepads) { pad.destroy(); } @@ -490,12 +498,12 @@ export class InputManager implements System { return; } - this.pointers[event.pointerId] = new Pointer(event, this._app, this.canvas, this.channels, slot); + this.pointers.set(event.pointerId, new Pointer(event, this._app, this.canvas, this.channels, slot)); this.flags.push(InputManagerFlag.PointerUpdate); } private handlePointerLeave(event: PointerEvent): void { - const pointer = this.pointers[event.pointerId]; + const pointer = this.pointers.get(event.pointerId); if (!pointer) { return; @@ -511,7 +519,7 @@ export class InputManager implements System { this.canvas.focus(); this.canvasFocusedValue = true; - const pointer = this.pointers[event.pointerId]; + const pointer = this.pointers.get(event.pointerId); if (!pointer) { return; @@ -525,7 +533,7 @@ export class InputManager implements System { } private handlePointerMove(event: PointerEvent): void { - const pointer = this.pointers[event.pointerId]; + const pointer = this.pointers.get(event.pointerId); if (!pointer) { return; @@ -537,7 +545,7 @@ export class InputManager implements System { } private handlePointerUp(event: PointerEvent): void { - const pointer = this.pointers[event.pointerId]; + const pointer = this.pointers.get(event.pointerId); if (!pointer) { return; @@ -551,7 +559,7 @@ export class InputManager implements System { } private handlePointerCancel(event: PointerEvent): void { - const pointer = this.pointers[event.pointerId]; + const pointer = this.pointers.get(event.pointerId); if (!pointer) { return; @@ -803,7 +811,7 @@ export class InputManager implements System { } private updatePointerEvents(): void { - for (const pointer of Object.values(this.pointers)) { + for (const pointer of this.pointers.values()) { const { stateFlags } = pointer; if (stateFlags.value === PointerStateFlag.None) { @@ -844,7 +852,7 @@ export class InputManager implements System { if (stateFlags.pop(PointerStateFlag.Leave)) { this.onPointerLeave.dispatch(pointer); - delete this.pointers[pointer.id]; + this.pointers.delete(pointer.id); } } } diff --git a/src/math/Polygon.ts b/src/math/Polygon.ts index 18c2f099..b6077823 100644 --- a/src/math/Polygon.ts +++ b/src/math/Polygon.ts @@ -221,10 +221,24 @@ export class Polygon implements ShapeLike { } public project(axis: Vector, result: Interval = new Interval()): Interval { - const normal = axis.clone().normalize(); - const projections = this._points.map(point => normal.dot(point.x, point.y)); + // Normalize the axis into scalars (project() is public and may receive an + // unnormalized axis; SAT already passes unit normals, so this is a no-op there). + const length = Math.sqrt(axis.x * axis.x + axis.y * axis.y) || 1; + const nx = axis.x / length; + const ny = axis.y / length; - return result.set(Math.min(...projections), Math.max(...projections)); + const points = this._points; + let min = Infinity; + let max = -Infinity; + + for (let i = 0; i < points.length; i++) { + const projection = nx * points[i].x + ny * points[i].y; + + if (projection < min) min = projection; + if (projection > max) max = projection; + } + + return result.set(min, max); } public contains(x: number, y: number): boolean { @@ -288,6 +302,11 @@ export class Polygon implements ShapeLike { this._edges.length = 0; } + /** + * Shared scratch `Polygon` instance for intermediate calculations. Never + * retain the reference across frames or async boundaries. + * @internal + */ public static get temp(): Polygon { if (temp === null) { temp = new Polygon(); diff --git a/src/math/index.ts b/src/math/index.ts index 573cbafb..ff4e5dd5 100644 --- a/src/math/index.ts +++ b/src/math/index.ts @@ -1,5 +1,9 @@ export * from './Circle'; export * from './CircleLike'; +// `./Collision` provides the collision TYPES (CollisionType, Collidable, +// CollisionResponse) — it does NOT export a `Collision` value despite the +// filename. The `Collision` VALUE below is the query-namespace facade +// (intersects.*/resolve.*) from `./collision-detection`. Keep these distinct. export * from './Collision'; export { Collision } from './collision-detection'; export * from './Ellipse'; diff --git a/src/rendering/Container.ts b/src/rendering/Container.ts index 3711e241..4763fd4a 100644 --- a/src/rendering/Container.ts +++ b/src/rendering/Container.ts @@ -238,7 +238,15 @@ export class Container extends RenderNode { } public override contains(x: number, y: number): boolean { - return this._children.some(child => child.contains(x, y)); + const children = this._children; + + for (let i = 0; i < children.length; i++) { + if (children[i].contains(x, y)) { + return true; + } + } + + return false; } protected override _invalidateChildrenTransform(): void { diff --git a/src/rendering/webgl2/WebGl2RepeatingSpriteRenderer.ts b/src/rendering/webgl2/WebGl2RepeatingSpriteRenderer.ts index 24c25c0e..0b86c035 100644 --- a/src/rendering/webgl2/WebGl2RepeatingSpriteRenderer.ts +++ b/src/rendering/webgl2/WebGl2RepeatingSpriteRenderer.ts @@ -384,9 +384,9 @@ export class WebGl2RepeatingSpriteRenderer extends AbstractWebGl2Renderer; +export type AssetInput = AnyAssetConfig | Asset; export type InferAssetResource = I extends Asset ? T : I extends { type: infer K extends keyof AssetDefinitions } ? AssetDefinitions[K]['resource'] : never; diff --git a/src/resources/Loader.ts b/src/resources/Loader.ts index 8f19884e..2c62e943 100644 --- a/src/resources/Loader.ts +++ b/src/resources/Loader.ts @@ -36,7 +36,7 @@ import { * with {@link Loader.load} and related methods. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type Loadable = abstract new (...args: any[]) => any; +export type Loadable = abstract new (...args: any[]) => unknown; /** Maps each key of an `AssetInput` map to its resolved runtime resource type. */ export type InferLoadedMap> = { @@ -694,7 +694,7 @@ export class Loader { const container = arg0 as Assets>; const items = Object.entries(container.entries).map(([alias, a]) => ({ alias, - asset: a as Asset, + asset: a, })); return this._createLoadingQueue(items, results => { @@ -1004,7 +1004,7 @@ export class Loader { const container = arg0 as Assets>; for (const [alias, a] of Object.entries(container.entries)) { - const assetRef = a as Asset; + const assetRef = a; const ctor = this._assetTypeMap.get(assetRef.type); if (!ctor) continue; diff --git a/test/core/scene-node-cache.test.ts b/test/core/scene-node-cache.test.ts index f1b986ab..4db14847 100644 --- a/test/core/scene-node-cache.test.ts +++ b/test/core/scene-node-cache.test.ts @@ -229,4 +229,46 @@ describe('SceneNode.getBounds() — dirty-flag cache', () => { parent.destroy(); }); + + test('cascade short-circuit skips no level: deep leaf move updates bounds at every ancestor', () => { + // A three-level chain whose extent is driven by the leaf, so a stale level + // would be observable as an un-grown getBounds() at that level. Containers + // sit at the origin; moving the leaf to a far corner must expand the union + // bounds (origin .. leaf) at every ancestor. + const grandparent = new Container(); + const parent = new Container(); + const leaf = new TestDrawable(10, 10); + + grandparent.addChild(parent); + parent.addChild(leaf); + leaf.setPosition(0, 0); + + // Warm every cache so each level holds a clean (non-dirty) bounds rect. + const grandparentW1 = grandparent.getBounds().width; + const parentW1 = parent.getBounds().width; + const leafW1 = leaf.getBounds().width; + + expect(leafW1).toBeCloseTo(10); + expect(parentW1).toBeCloseTo(10); + expect(grandparentW1).toBeCloseTo(10); + + // Move the deepest leaf once. The upward cascade must re-mark every + // ancestor; the short-circuit may only stop at an already-dirty one. + leaf.setPosition(300, 400); + + // Each level must report the grown bounds — none may be skipped. The leaf + // itself merely translates (width unchanged), but every container's union + // with the far-moved leaf must now span out to the leaf. + expect(leaf.getBounds().x).toBeCloseTo(300); + expect(leaf.getBounds().y).toBeCloseTo(400); + + expect(parent.getBounds().width).toBeCloseTo(310); + expect(parent.getBounds().height).toBeCloseTo(410); + expect(grandparent.getBounds().width).toBeCloseTo(310); + expect(grandparent.getBounds().height).toBeCloseTo(410); + + leaf.destroy(); + parent.destroy(); + grandparent.destroy(); + }); }); diff --git a/test/input/pointer-channels.test.ts b/test/input/pointer-channels.test.ts index 63ca922d..9cad6aa4 100644 --- a/test/input/pointer-channels.test.ts +++ b/test/input/pointer-channels.test.ts @@ -441,7 +441,7 @@ describe('Pointer coordinate mapping — scaled canvas', () => { return canvas; }; - const getPointer = (im: InputManager, id: number): Pointer => (im as unknown as { pointers: Record }).pointers[id]; + const getPointer = (im: InputManager, id: number): Pointer => (im as unknown as { pointers: Map }).pointers.get(id)!; test('constructor maps CSS-display coordinates to design pixels', () => { const canvas = createScaledCanvas(); @@ -508,7 +508,7 @@ describe('Pointer coordinate mapping — pixelRatio > 1', () => { return canvas; }; - const getPointer = (im: InputManager, id: number): Pointer => (im as unknown as { pointers: Record }).pointers[id]; + const getPointer = (im: InputManager, id: number): Pointer => (im as unknown as { pointers: Map }).pointers.get(id)!; test('pointer position is in design pixels regardless of pixelRatio', () => { const canvas = createDprCanvas(); diff --git a/test/math/polygon.test.ts b/test/math/polygon.test.ts index 63a9aab6..c63fe4a8 100644 --- a/test/math/polygon.test.ts +++ b/test/math/polygon.test.ts @@ -1,6 +1,53 @@ +import { Interval } from '#math/Interval'; import { Polygon } from '#math/Polygon'; import { Vector } from '#math/Vector'; +describe('Polygon.project()', () => { + // Unit square centred at origin with vertices at (0,0), (10,0), (10,10), (0,10). + const makeSquare = () => new Polygon([new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10)]); + + test('(a) axis-aligned projection on (1,0) gives correct min/max', () => { + const polygon = makeSquare(); + const result = polygon.project(new Vector(1, 0)); + + expect(result.min).toBe(0); + expect(result.max).toBe(10); + + polygon.destroy(); + }); + + test('(a) axis-aligned projection on (0,1) gives correct min/max', () => { + const polygon = makeSquare(); + const result = polygon.project(new Vector(0, 1)); + + expect(result.min).toBe(0); + expect(result.max).toBe(10); + + polygon.destroy(); + }); + + test('(b) unnormalized axis (2,0) produces the same result as (1,0)', () => { + const polygon = makeSquare(); + const normalized = polygon.project(new Vector(1, 0)); + const unnormalized = polygon.project(new Vector(2, 0)); + + expect(unnormalized.min).toBeCloseTo(normalized.min); + expect(unnormalized.max).toBeCloseTo(normalized.max); + + polygon.destroy(); + }); + + test('(c) provided result interval is returned as the same reference', () => { + const polygon = makeSquare(); + const result = new Interval(); + const returned = polygon.project(new Vector(1, 0), result); + + expect(returned).toBe(result); + + polygon.destroy(); + }); +}); + describe('Polygon', () => { test('setPoints handles shrinking point arrays safely', () => { const polygon = new Polygon([new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10)]);