@jgengine/core 0.19.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +364 -0
- package/dist/ai/decisionGraph.d.ts +2 -0
- package/dist/ai/decisionGraph.js +6 -0
- package/dist/ai/flock.d.ts +1 -1
- package/dist/ai/flock.js +7 -4
- package/dist/anim/animGraph.d.ts +33 -2
- package/dist/anim/animGraph.js +200 -12
- package/dist/anim/locomotionGraph.d.ts +11 -3
- package/dist/anim/locomotionGraph.js +25 -15
- package/dist/audio/audioFalloff.d.ts +4 -0
- package/dist/audio/voiceAllocator.d.ts +46 -0
- package/dist/audio/voiceAllocator.js +76 -0
- package/dist/combat/abilityKit.d.ts +19 -0
- package/dist/combat/abilityKit.js +40 -4
- package/dist/combat/death.d.ts +4 -3
- package/dist/combat/death.js +3 -3
- package/dist/combat/magazine.d.ts +9 -0
- package/dist/combat/magazine.js +24 -2
- package/dist/combat/projectiles.d.ts +94 -2
- package/dist/combat/projectiles.js +282 -61
- package/dist/combat/regenShield.d.ts +14 -0
- package/dist/combat/regenShield.js +23 -7
- package/dist/crafting/recipeCatalog.d.ts +16 -0
- package/dist/crafting/recipeCatalog.js +55 -0
- package/dist/economy/auctionBook.d.ts +1 -1
- package/dist/economy/auctionBook.js +11 -1
- package/dist/economy/listingBook.d.ts +1 -1
- package/dist/economy/listingBook.js +11 -1
- package/dist/economy/resourceLedger.js +3 -3
- package/dist/economy/shopStock.js +7 -6
- package/dist/economy/techTree.js +7 -6
- package/dist/economy/unlockPoints.js +3 -6
- package/dist/editor/commandApply.js +89 -16
- package/dist/editor/commands.d.ts +38 -1
- package/dist/editor/commands.js +320 -3
- package/dist/editor/creatorStorage.d.ts +59 -0
- package/dist/editor/creatorStorage.js +164 -0
- package/dist/editor/document.d.ts +4 -0
- package/dist/editor/document.js +116 -17
- package/dist/editor/index.d.ts +3 -0
- package/dist/editor/index.js +1 -0
- package/dist/editor/liveSync.d.ts +4 -1
- package/dist/editor/liveSync.js +26 -10
- package/dist/editor/materialAuthoring.d.ts +14 -0
- package/dist/editor/materialAuthoring.js +38 -0
- package/dist/editor/modelNodeVisibility.d.ts +11 -0
- package/dist/editor/modelNodeVisibility.js +29 -0
- package/dist/editor/movementCatalog.d.ts +90 -0
- package/dist/editor/movementCatalog.js +144 -0
- package/dist/editor/simulation.d.ts +60 -0
- package/dist/editor/simulation.js +202 -0
- package/dist/editor/staticPrefab.d.ts +17 -0
- package/dist/editor/staticPrefab.js +51 -0
- package/dist/editor/terrainMaterials.d.ts +10 -0
- package/dist/editor/terrainMaterials.js +13 -0
- package/dist/editor/types.d.ts +11 -0
- package/dist/game/cameraConfig.d.ts +3 -1
- package/dist/game/clipRoles.d.ts +2 -1
- package/dist/game/clipRoles.js +7 -3
- package/dist/game/contentValidation.d.ts +69 -0
- package/dist/game/contentValidation.js +118 -0
- package/dist/game/controlGate.d.ts +8 -0
- package/dist/game/controlGate.js +43 -6
- package/dist/game/defineGame.d.ts +32 -3
- package/dist/game/dialogueGraph.d.ts +20 -0
- package/dist/game/dialogueGraph.js +107 -0
- package/dist/game/events.d.ts +5 -0
- package/dist/game/keyValueStore.d.ts +17 -4
- package/dist/game/keyValueStore.js +35 -23
- package/dist/game/lootModifiers.d.ts +17 -0
- package/dist/game/lootModifiers.js +26 -0
- package/dist/game/narrativeAuthoring.d.ts +67 -0
- package/dist/game/narrativeAuthoring.js +122 -0
- package/dist/game/playableGame.d.ts +52 -20
- package/dist/game/quest.d.ts +15 -0
- package/dist/game/quest.js +209 -63
- package/dist/game/questCatalog.d.ts +50 -0
- package/dist/game/questCatalog.js +205 -0
- package/dist/game/saveStore.d.ts +6 -5
- package/dist/game/saveStore.js +57 -57
- package/dist/game/systemRuntime.d.ts +2 -2
- package/dist/game/systemRuntime.js +84 -33
- package/dist/gameplay.d.ts +3 -0
- package/dist/gameplay.js +3 -0
- package/dist/interaction/promptRegistry.d.ts +5 -4
- package/dist/interaction/promptRegistry.js +8 -2
- package/dist/inventory/inventoryModel.js +9 -6
- package/dist/inventory/shapedGrid.d.ts +48 -9
- package/dist/inventory/shapedGrid.js +69 -10
- package/dist/item/durability.js +4 -0
- package/dist/item/itemInstanceRegistry.d.ts +32 -1
- package/dist/item/itemInstanceRegistry.js +111 -7
- package/dist/item/itemgen.d.ts +1 -1
- package/dist/item/itemgen.js +1 -1
- package/dist/item/modularItem.js +7 -1
- package/dist/material/appearanceSignals.d.ts +53 -0
- package/dist/material/appearanceSignals.js +47 -0
- package/dist/material/hairCards.d.ts +47 -0
- package/dist/material/hairCards.js +136 -0
- package/dist/material/materialAsset.d.ts +162 -0
- package/dist/material/materialAsset.js +382 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +348 -1
- package/dist/movement/characterController.d.ts +5 -2
- package/dist/movement/characterController.js +50 -23
- package/dist/movement/freeFlight.d.ts +2 -2
- package/dist/movement/freeFlight.js +3 -3
- package/dist/movement/movementModel.d.ts +24 -1
- package/dist/movement/movementModel.js +74 -7
- package/dist/movement/playerMovement.d.ts +28 -1
- package/dist/movement/playerMovement.js +425 -102
- package/dist/movement/poseState.d.ts +2 -0
- package/dist/movement/poseState.js +29 -1
- package/dist/movement/solidObstacles.d.ts +3 -1
- package/dist/movement/solidObstacles.js +8 -5
- package/dist/movement/terrainGrade.d.ts +13 -0
- package/dist/movement/terrainGrade.js +33 -0
- package/dist/movement/voxelController.d.ts +2 -2
- package/dist/movement/voxelController.js +3 -3
- package/dist/nav/navGrid.d.ts +19 -1
- package/dist/nav/navGrid.js +26 -10
- package/dist/nav/solidRoute.d.ts +50 -0
- package/dist/nav/solidRoute.js +144 -0
- package/dist/physics/ballisticSweep.d.ts +10 -3
- package/dist/physics/ballisticSweep.js +53 -29
- package/dist/physics/forceVolume.d.ts +41 -0
- package/dist/physics/forceVolume.js +88 -0
- package/dist/physics/kinematicVehicle.d.ts +8 -3
- package/dist/physics/kinematicVehicle.js +2 -2
- package/dist/physics/physicsWorldBackend.js +19 -3
- package/dist/physics/spatialGrid.d.ts +1 -1
- package/dist/physics/spatialGrid.js +8 -9
- package/dist/physics/vehicleDynamics.d.ts +3 -2
- package/dist/physics/vehicleDynamics.js +2 -2
- package/dist/physics/vehicleObstacles.d.ts +3 -2
- package/dist/physics/vehicleObstacles.js +29 -5
- package/dist/runtime/cameraDirector.d.ts +39 -2
- package/dist/runtime/cameraDirector.js +162 -0
- package/dist/runtime/commandDispatch.d.ts +14 -0
- package/dist/runtime/commandDispatch.js +19 -0
- package/dist/runtime/context/combat.d.ts +11 -4
- package/dist/runtime/context/combat.js +178 -11
- package/dist/runtime/context/deathLoot.d.ts +9 -8
- package/dist/runtime/context/deathLoot.js +16 -16
- package/dist/runtime/context/scene.d.ts +2 -0
- package/dist/runtime/context/scene.js +13 -21
- package/dist/runtime/descriptors/baseline.js +34 -3
- package/dist/runtime/descriptors/features.d.ts +2 -1
- package/dist/runtime/descriptors/features.js +18 -0
- package/dist/runtime/gameContext.js +79 -13
- package/dist/runtime/gameContextTypes.d.ts +14 -0
- package/dist/runtime/headlessRunner.d.ts +3 -1
- package/dist/runtime/headlessRunner.js +11 -11
- package/dist/runtime/hostedGameRunner.d.ts +24 -1
- package/dist/runtime/hostedGameRunner.js +156 -10
- package/dist/runtime/hostedWorldSession.d.ts +17 -5
- package/dist/runtime/hostedWorldSession.js +154 -16
- package/dist/runtime/hostedWorldStore.d.ts +2 -2
- package/dist/runtime/inputRecorder.d.ts +1 -1
- package/dist/runtime/inputRecorder.js +21 -1
- package/dist/runtime/inputSnapshot.d.ts +16 -4
- package/dist/runtime/inputSnapshot.js +31 -3
- package/dist/runtime/runtimeSave.d.ts +10 -6
- package/dist/runtime/runtimeSave.js +11 -5
- package/dist/runtime/simContext.d.ts +8 -3
- package/dist/runtime/simContext.js +7 -3
- package/dist/runtime/simSnapshot.js +2 -0
- package/dist/runtime/worldReplication.d.ts +2 -0
- package/dist/runtime/worldReplication.js +1 -1
- package/dist/scene/assetCatalog.d.ts +2 -0
- package/dist/scene/behaviorRuntime.d.ts +26 -5
- package/dist/scene/behaviorRuntime.js +263 -16
- package/dist/scene/behaviors.d.ts +30 -1
- package/dist/scene/behaviors.js +29 -0
- package/dist/scene/entityStore.d.ts +14 -3
- package/dist/scene/entityStore.js +45 -9
- package/dist/scene/mount.d.ts +9 -0
- package/dist/scene/mount.js +35 -0
- package/dist/scene/possession.d.ts +2 -0
- package/dist/scene/possession.js +21 -1
- package/dist/scene/sceneRaycast.d.ts +7 -0
- package/dist/scene/sceneRaycast.js +55 -0
- package/dist/scene/spatial.d.ts +20 -1
- package/dist/scene/spatial.js +94 -2
- package/dist/scene/vehicleSeat.d.ts +36 -2
- package/dist/scene/vehicleSeat.js +89 -2
- package/dist/stats/accumulatorMeter.d.ts +10 -0
- package/dist/stats/accumulatorMeter.js +6 -0
- package/dist/stats/eventMeter.d.ts +9 -2
- package/dist/stats/eventMeter.js +5 -2
- package/dist/store/defineStore.d.ts +3 -1
- package/dist/store/defineStore.js +3 -1
- package/dist/store/observableKeyedStore.d.ts +1 -1
- package/dist/store/observableKeyedStore.js +8 -6
- package/dist/time/simClock.d.ts +6 -2
- package/dist/time/simClock.js +5 -0
- package/dist/vfx/particleDirector.d.ts +58 -6
- package/dist/vfx/particleDirector.js +113 -22
- package/dist/vfx/particles.d.ts +68 -5
- package/dist/vfx/particles.js +284 -32
- package/dist/visibility/assetStreaming.d.ts +21 -3
- package/dist/visibility/assetStreaming.js +108 -24
- package/dist/visibility/settings.d.ts +8 -2
- package/dist/visibility/settings.js +3 -0
- package/dist/visibility/visibilitySystem.js +1 -0
- package/dist/work/expedition.d.ts +110 -0
- package/dist/work/expedition.js +195 -0
- package/dist/work/staffedStation.d.ts +29 -0
- package/dist/work/staffedStation.js +27 -0
- package/dist/world/authoredAnimation.d.ts +38 -0
- package/dist/world/authoredAnimation.js +99 -0
- package/dist/world/authoredObjects.d.ts +24 -4
- package/dist/world/authoredObjects.js +43 -16
- package/dist/world/authoredSimulation.d.ts +74 -0
- package/dist/world/authoredSimulation.js +302 -0
- package/dist/world/authoredWeather.d.ts +120 -0
- package/dist/world/authoredWeather.js +314 -0
- package/dist/world/buildingKit.d.ts +8 -3
- package/dist/world/buildingKit.js +21 -8
- package/dist/world/environmentForces.d.ts +21 -0
- package/dist/world/environmentForces.js +42 -0
- package/dist/world/features.d.ts +3 -3
- package/dist/world/footprintGrid.d.ts +55 -0
- package/dist/world/footprintGrid.js +201 -0
- package/dist/world/grassKind.d.ts +10 -0
- package/dist/world/grassKind.js +22 -0
- package/dist/world/pathTerrain.d.ts +1 -1
- package/dist/world/terraform.d.ts +2 -2
- package/dist/world/terraform.js +46 -18
- package/dist/world/weather.d.ts +17 -0
- package/dist/world/weather.js +116 -20
- package/dist/world/worldSolids.d.ts +11 -0
- package/dist/world/worldSolids.js +112 -1
- package/dist/world.d.ts +5 -5
- package/dist/world.js +4 -4
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +23 -2
- package/skills/jgengine/api.md +28 -17
- package/skills/jgengine/recipes/minimal-game.md +7 -2
- package/skills/jgengine-combat/SKILL.md +8 -2
- package/skills/jgengine-combat/api.md +22 -10
- package/skills/jgengine-combat/recipes/portable-runtime-state.md +60 -6
- package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +37 -3
- package/skills/jgengine-combat/reference.md +24 -2
- package/skills/jgengine-gameplay/SKILL.md +63 -0
- package/skills/jgengine-gameplay/api.md +131 -37
- package/skills/jgengine-gameplay/capabilities.md +68 -0
- package/skills/jgengine-gameplay/recipes/device-save-status.md +46 -0
- package/skills/jgengine-gameplay/recipes/expeditions.md +108 -0
- package/skills/jgengine-gameplay/recipes/narrative-authoring.md +76 -0
- package/skills/jgengine-gameplay/recipes/paged-item-storage.md +101 -0
- package/skills/jgengine-gameplay/recipes/quest-authoring.md +63 -0
- package/skills/jgengine-gameplay/recipes/shaped-inventory.md +50 -0
- package/skills/jgengine-gameplay/reference-content-validation.md +74 -0
- package/skills/jgengine-gameplay/reference-systems.md +119 -0
- package/skills/jgengine-world/SKILL.md +12 -3
- package/skills/jgengine-world/api.md +201 -58
- package/skills/jgengine-world/capabilities.md +116 -3
- package/skills/jgengine-world/recipes/character-animation.md +31 -8
- package/skills/jgengine-world/recipes/controls.md +4 -0
- package/skills/jgengine-world/recipes/npc-senses.md +12 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
- package/skills/jgengine-world/reference.md +167 -5
- package/skills/jgengine-world/visibility.md +28 -1
package/dist/vfx/particles.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ForceFieldConfig } from "../physics/forceVolume.js";
|
|
1
2
|
/** A 3D vector `[x, y, z]`. */
|
|
2
3
|
export type Vec3 = readonly [number, number, number];
|
|
3
4
|
/** A `[min, max]` range a spawned particle draws uniformly from. */
|
|
@@ -10,6 +11,41 @@ export interface Curve {
|
|
|
10
11
|
start: number;
|
|
11
12
|
end: number;
|
|
12
13
|
}
|
|
14
|
+
/** Serializable spawn volume in emitter coordinates; cone widens from its base along +Y. */
|
|
15
|
+
export interface ParticleSpawnShape {
|
|
16
|
+
kind: "disc" | "sphere" | "cone";
|
|
17
|
+
radius: number;
|
|
18
|
+
height?: number;
|
|
19
|
+
/** Spawn on the outer surface instead of filling the volume. */
|
|
20
|
+
surface?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Bounded particle contact policy; a query adapter can replace the fallback horizontal plane. */
|
|
23
|
+
export interface ParticleCollisionConfig {
|
|
24
|
+
planeY?: number;
|
|
25
|
+
response?: "kill" | "bounce";
|
|
26
|
+
restitution?: number;
|
|
27
|
+
maxQueries?: number;
|
|
28
|
+
maxEvents?: number;
|
|
29
|
+
}
|
|
30
|
+
/** One contact from an injected scene query, in simulation coordinates. */
|
|
31
|
+
export interface ParticleCollisionHit {
|
|
32
|
+
position: Vec3;
|
|
33
|
+
normal: Vec3;
|
|
34
|
+
}
|
|
35
|
+
/** Presentation events never authorize gameplay damage or physical impulses. */
|
|
36
|
+
export interface ParticleEvent {
|
|
37
|
+
type: "collision" | "death";
|
|
38
|
+
particleId: number;
|
|
39
|
+
position: Vec3;
|
|
40
|
+
velocity: Vec3;
|
|
41
|
+
normal?: Vec3;
|
|
42
|
+
}
|
|
43
|
+
/** Optional geometry adapter; queries are capped independently of rendering. */
|
|
44
|
+
export interface ParticleSystemOptions {
|
|
45
|
+
collision?: (from: Vec3, to: Vec3) => ParticleCollisionHit | null;
|
|
46
|
+
/** External cosmetic acceleration; return `out` to reuse its allocation. */
|
|
47
|
+
acceleration?: (position: Vec3, velocity: Vec3, out: [number, number, number]) => Vec3;
|
|
48
|
+
}
|
|
13
49
|
/**
|
|
14
50
|
* A particle emitter: how particles spawn and how each one evolves over its life.
|
|
15
51
|
* Every field is data — no functions — so an emitter is fully serializable and an
|
|
@@ -24,6 +60,7 @@ export interface EmitterConfig {
|
|
|
24
60
|
position?: Vec3;
|
|
25
61
|
/** Half-extents of a box the spawn point is jittered within. Default `[0, 0, 0]`. */
|
|
26
62
|
spawnJitter?: Vec3;
|
|
63
|
+
spawnShape?: ParticleSpawnShape;
|
|
27
64
|
/** Particle lifetime in seconds. Default `{ min: 1, max: 1 }`. */
|
|
28
65
|
lifetime?: Range;
|
|
29
66
|
/** Initial speed along the emit direction, in units/second. Default `{ min: 1, max: 1 }`. */
|
|
@@ -44,8 +81,13 @@ export interface EmitterConfig {
|
|
|
44
81
|
colorEnd?: number;
|
|
45
82
|
/** Opacity over life, each `0..1`. Default `{ start: 1, end: 0 }` (fade out). */
|
|
46
83
|
alpha?: Curve;
|
|
47
|
-
/**
|
|
84
|
+
/** Seed for deterministic births; retuning it resets future randomness. */
|
|
48
85
|
seed?: string | number;
|
|
86
|
+
/** Shared physical field descriptors, sampled as cosmetic acceleration; at most eight per particle. */
|
|
87
|
+
forces?: readonly ForceFieldConfig[];
|
|
88
|
+
collision?: ParticleCollisionConfig;
|
|
89
|
+
/** Enable bounded death events. Collision events use `collision.maxEvents`. */
|
|
90
|
+
deathEvents?: boolean;
|
|
49
91
|
}
|
|
50
92
|
/**
|
|
51
93
|
* Read-only packed buffers of the live particles, laid out for a renderer to
|
|
@@ -62,6 +104,11 @@ export interface ParticleBuffers {
|
|
|
62
104
|
colors: Float32Array;
|
|
63
105
|
/** `count` floats: opacity `0..1` per particle. */
|
|
64
106
|
alphas: Float32Array;
|
|
107
|
+
/** Stable birth ids, retained through pool compaction. */
|
|
108
|
+
ids: Float64Array;
|
|
109
|
+
/** Packed velocity and prior integration positions for streak/ribbon outputs. */
|
|
110
|
+
velocities: Float32Array;
|
|
111
|
+
previousPositions: Float32Array;
|
|
65
112
|
}
|
|
66
113
|
/** Serializable simulation state for save/restore and deterministic replay. */
|
|
67
114
|
export interface ParticleSnapshot {
|
|
@@ -69,6 +116,11 @@ export interface ParticleSnapshot {
|
|
|
69
116
|
accumulator: number;
|
|
70
117
|
count: number;
|
|
71
118
|
data: number[];
|
|
119
|
+
running?: boolean;
|
|
120
|
+
nextId?: number;
|
|
121
|
+
ids?: number[];
|
|
122
|
+
previousPositions?: number[];
|
|
123
|
+
config?: EmitterConfig;
|
|
72
124
|
}
|
|
73
125
|
/** A live, dt-driven particle simulation. */
|
|
74
126
|
export interface ParticleSystem {
|
|
@@ -76,14 +128,20 @@ export interface ParticleSystem {
|
|
|
76
128
|
update(dt: number): void;
|
|
77
129
|
/** Spawn `n` particles immediately (a one-shot puff/explosion), independent of `rate`. */
|
|
78
130
|
emit(n: number): void;
|
|
131
|
+
/** Resume continuous emission without replacing the live pool. */
|
|
132
|
+
start(): void;
|
|
133
|
+
/** Stop spawning while existing particles continue aging. */
|
|
134
|
+
stop(): void;
|
|
135
|
+
/** Consume bounded presentation collision/death events exactly once. */
|
|
136
|
+
drainEvents(): ParticleEvent[];
|
|
79
137
|
/** Live particle count. */
|
|
80
138
|
count(): number;
|
|
81
139
|
/** The packed buffers for rendering (valid until the next `update`/`emit`). */
|
|
82
140
|
buffers(): ParticleBuffers;
|
|
83
141
|
/** Kill all particles and reset the emission accumulator (keeps the seed stream). */
|
|
84
142
|
clear(): void;
|
|
85
|
-
/** Patch
|
|
86
|
-
configure(patch: Partial<EmitterConfig
|
|
143
|
+
/** Patch tuning in place; `replace` resets omitted fields to defaults while retaining live particles. */
|
|
144
|
+
configure(patch: Partial<EmitterConfig>, replace?: boolean): void;
|
|
87
145
|
subscribe(listener: () => void): () => void;
|
|
88
146
|
snapshot(): ParticleSnapshot;
|
|
89
147
|
restore(snapshot: ParticleSnapshot): void;
|
|
@@ -98,6 +156,11 @@ export interface ParticleSystem {
|
|
|
98
156
|
* embers, magic, or confetti. Travel/gameplay stays elsewhere; this owns only
|
|
99
157
|
* the spawn-integrate-fade lifecycle.
|
|
100
158
|
*
|
|
101
|
-
* @capability particle-system deterministic pooled particle emitter with
|
|
159
|
+
* @capability particle-system deterministic pooled particle emitter with spawn volumes, force fields, bounded collision/death events, live tuning, SoA render buffers, and serializable state
|
|
160
|
+
*/
|
|
161
|
+
export declare function createParticleSystem(config?: EmitterConfig, options?: ParticleSystemOptions): ParticleSystem;
|
|
162
|
+
/**
|
|
163
|
+
* Validate the serializable spawn/update descriptor before authoring or loading it.
|
|
164
|
+
* @capability particle-emitter-validation validate finite spawn volumes, force fields, lifetimes, appearance curves, and bounded particle collision policies
|
|
102
165
|
*/
|
|
103
|
-
export declare function
|
|
166
|
+
export declare function validateEmitterConfig(value: unknown): string[];
|
package/dist/vfx/particles.js
CHANGED
|
@@ -1,25 +1,15 @@
|
|
|
1
|
+
import { sampleForceField, validateForceField } from "../physics/forceVolume.js";
|
|
1
2
|
import { hashString, randomSeedFrom, stepRandomSeed } from "../random/rng.js";
|
|
2
3
|
function clamp01(v) {
|
|
3
4
|
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
4
5
|
}
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
* Structure-of-Arrays buffers a renderer uploads straight to the GPU. It is
|
|
8
|
-
* dt-driven (call `update(dt)` each frame) and deterministic — all randomness
|
|
9
|
-
* flows from an injected `seed`, so the same seed and dt sequence reproduce the
|
|
10
|
-
* same frames, and `snapshot`/`restore` round-trips the live pool. Nothing here
|
|
11
|
-
* is combat- or genre-specific: configure it for smoke, sparks, rain, dust,
|
|
12
|
-
* embers, magic, or confetti. Travel/gameplay stays elsewhere; this owns only
|
|
13
|
-
* the spawn-integrate-fade lifecycle.
|
|
14
|
-
*
|
|
15
|
-
* @capability particle-system deterministic pooled particle emitter with SoA render buffers, burst/continuous emission, gravity/drag, size/color/alpha-over-life, and serializable state
|
|
16
|
-
*/
|
|
17
|
-
export function createParticleSystem(config = {}) {
|
|
18
|
-
const cfg = {
|
|
6
|
+
function emitterDefaults(config) {
|
|
7
|
+
return {
|
|
19
8
|
max: config.max ?? 512,
|
|
20
9
|
rate: config.rate ?? 0,
|
|
21
10
|
position: config.position ?? [0, 0, 0],
|
|
22
11
|
spawnJitter: config.spawnJitter ?? [0, 0, 0],
|
|
12
|
+
spawnShape: config.spawnShape,
|
|
23
13
|
lifetime: config.lifetime ?? { min: 1, max: 1 },
|
|
24
14
|
speed: config.speed ?? { min: 1, max: 1 },
|
|
25
15
|
direction: config.direction ?? [0, 1, 0],
|
|
@@ -30,8 +20,26 @@ export function createParticleSystem(config = {}) {
|
|
|
30
20
|
colorStart: config.colorStart ?? 0xffffff,
|
|
31
21
|
colorEnd: config.colorEnd ?? config.colorStart ?? 0xffffff,
|
|
32
22
|
alpha: config.alpha ?? { start: 1, end: 0 },
|
|
23
|
+
forces: config.forces,
|
|
24
|
+
collision: config.collision,
|
|
25
|
+
deathEvents: config.deathEvents ?? false,
|
|
33
26
|
};
|
|
34
|
-
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A generic, allocation-aware particle system: one emitter, a fixed pool, and
|
|
30
|
+
* Structure-of-Arrays buffers a renderer uploads straight to the GPU. It is
|
|
31
|
+
* dt-driven (call `update(dt)` each frame) and deterministic — all randomness
|
|
32
|
+
* flows from an injected `seed`, so the same seed and dt sequence reproduce the
|
|
33
|
+
* same frames, and `snapshot`/`restore` round-trips the live pool. Nothing here
|
|
34
|
+
* is combat- or genre-specific: configure it for smoke, sparks, rain, dust,
|
|
35
|
+
* embers, magic, or confetti. Travel/gameplay stays elsewhere; this owns only
|
|
36
|
+
* the spawn-integrate-fade lifecycle.
|
|
37
|
+
*
|
|
38
|
+
* @capability particle-system deterministic pooled particle emitter with spawn volumes, force fields, bounded collision/death events, live tuning, SoA render buffers, and serializable state
|
|
39
|
+
*/
|
|
40
|
+
export function createParticleSystem(config = {}, options = {}) {
|
|
41
|
+
const cfg = emitterDefaults(config);
|
|
42
|
+
const max = Number.isFinite(cfg.max) ? Math.max(1, Math.min(65536, Math.floor(cfg.max))) : 512;
|
|
35
43
|
// SoA live pool.
|
|
36
44
|
const px = new Float32Array(max);
|
|
37
45
|
const py = new Float32Array(max);
|
|
@@ -41,6 +49,8 @@ export function createParticleSystem(config = {}) {
|
|
|
41
49
|
const vz = new Float32Array(max);
|
|
42
50
|
const age = new Float32Array(max);
|
|
43
51
|
const life = new Float32Array(max);
|
|
52
|
+
const ids = new Float64Array(max);
|
|
53
|
+
const previous = new Float32Array(max * 3);
|
|
44
54
|
// Per-particle baked endpoints (so mid-flight config changes don't recolor existing particles).
|
|
45
55
|
const sizeS = new Float32Array(max);
|
|
46
56
|
const sizeE = new Float32Array(max);
|
|
@@ -57,8 +67,19 @@ export function createParticleSystem(config = {}) {
|
|
|
57
67
|
const outSize = new Float32Array(max);
|
|
58
68
|
const outColor = new Float32Array(max * 3);
|
|
59
69
|
const outAlpha = new Float32Array(max);
|
|
70
|
+
const outVelocity = new Float32Array(max * 3);
|
|
71
|
+
const renderBuffers = { count: 0, positions: outPos, sizes: outSize, colors: outColor,
|
|
72
|
+
alphas: outAlpha, ids, velocities: outVelocity, previousPositions: previous };
|
|
60
73
|
let live = 0;
|
|
61
74
|
let accumulator = 0;
|
|
75
|
+
let running = true;
|
|
76
|
+
let nextId = 1;
|
|
77
|
+
let events = [];
|
|
78
|
+
let configuredSeed = config.seed;
|
|
79
|
+
const queryFrom = [0, 0, 0];
|
|
80
|
+
const queryTo = [0, 0, 0];
|
|
81
|
+
const sampledForce = [0, 0, 0];
|
|
82
|
+
const queryVelocity = [0, 0, 0];
|
|
62
83
|
let seed = randomSeedFrom(typeof config.seed === "number" ? config.seed : hashString(String(config.seed ?? "particles")));
|
|
63
84
|
const listeners = new Set();
|
|
64
85
|
function notify() {
|
|
@@ -80,11 +101,35 @@ export function createParticleSystem(config = {}) {
|
|
|
80
101
|
if (live >= max)
|
|
81
102
|
return;
|
|
82
103
|
const i = live++;
|
|
104
|
+
ids[i] = nextId++;
|
|
83
105
|
const [cr0, cg0, cb0] = unpackColor(cfg.colorStart);
|
|
84
106
|
const [cr1, cg1, cb1] = unpackColor(cfg.colorEnd);
|
|
85
107
|
px[i] = cfg.position[0] + (rand() * 2 - 1) * cfg.spawnJitter[0];
|
|
86
108
|
py[i] = cfg.position[1] + (rand() * 2 - 1) * cfg.spawnJitter[1];
|
|
87
109
|
pz[i] = cfg.position[2] + (rand() * 2 - 1) * cfg.spawnJitter[2];
|
|
110
|
+
const shape = cfg.spawnShape;
|
|
111
|
+
if (shape !== undefined) {
|
|
112
|
+
const theta = rand() * Math.PI * 2;
|
|
113
|
+
if (shape.kind === "sphere") {
|
|
114
|
+
const y = rand() * 2 - 1;
|
|
115
|
+
const radius = shape.radius * (shape.surface ? 1 : Math.cbrt(rand()));
|
|
116
|
+
const horizontal = Math.sqrt(Math.max(0, 1 - y * y)) * radius;
|
|
117
|
+
px[i] += Math.cos(theta) * horizontal;
|
|
118
|
+
py[i] += y * radius;
|
|
119
|
+
pz[i] += Math.sin(theta) * horizontal;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const heightFraction = shape.kind === "cone" ? (shape.surface ? Math.sqrt(rand()) : Math.cbrt(rand())) : 1;
|
|
123
|
+
const radius = shape.radius * heightFraction * (shape.surface ? 1 : Math.sqrt(rand()));
|
|
124
|
+
px[i] += Math.cos(theta) * radius;
|
|
125
|
+
pz[i] += Math.sin(theta) * radius;
|
|
126
|
+
if (shape.kind === "cone")
|
|
127
|
+
py[i] += (shape.height ?? shape.radius) * heightFraction;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
previous[i * 3] = px[i];
|
|
131
|
+
previous[i * 3 + 1] = py[i];
|
|
132
|
+
previous[i * 3 + 2] = pz[i];
|
|
88
133
|
// Direction: base dir perturbed within a cone of half-angle `spread`.
|
|
89
134
|
let dx = cfg.direction[0];
|
|
90
135
|
let dy = cfg.direction[1];
|
|
@@ -138,6 +183,10 @@ export function createParticleSystem(config = {}) {
|
|
|
138
183
|
vz[i] = vz[last];
|
|
139
184
|
age[i] = age[last];
|
|
140
185
|
life[i] = life[last];
|
|
186
|
+
ids[i] = ids[last];
|
|
187
|
+
previous[i * 3] = previous[last * 3];
|
|
188
|
+
previous[i * 3 + 1] = previous[last * 3 + 1];
|
|
189
|
+
previous[i * 3 + 2] = previous[last * 3 + 2];
|
|
141
190
|
sizeS[i] = sizeS[last];
|
|
142
191
|
sizeE[i] = sizeE[last];
|
|
143
192
|
r0[i] = r0[last];
|
|
@@ -151,47 +200,128 @@ export function createParticleSystem(config = {}) {
|
|
|
151
200
|
}
|
|
152
201
|
live--;
|
|
153
202
|
}
|
|
203
|
+
function bounded(value, fallback, cap) {
|
|
204
|
+
return Number.isFinite(value) ? Math.max(0, Math.min(cap, Math.floor(value))) : fallback;
|
|
205
|
+
}
|
|
206
|
+
function recordEvent(type, i, normal) {
|
|
207
|
+
events.push({ type, particleId: ids[i], position: [px[i], py[i], pz[i]], velocity: [vx[i], vy[i], vz[i]],
|
|
208
|
+
...(normal === undefined ? {} : { normal: [normal[0], normal[1], normal[2]] }) });
|
|
209
|
+
}
|
|
154
210
|
const system = {
|
|
155
211
|
update(dt) {
|
|
156
|
-
if (dt <= 0)
|
|
212
|
+
if (!Number.isFinite(dt) || dt <= 0)
|
|
157
213
|
return;
|
|
158
214
|
const damp = cfg.drag > 0 ? Math.max(0, 1 - cfg.drag * dt) : 1;
|
|
159
|
-
|
|
215
|
+
const contact = cfg.collision;
|
|
216
|
+
const queryLimit = Math.min(max, bounded(contact?.maxQueries, 64, 4096));
|
|
217
|
+
const eventLimit = bounded(contact?.maxEvents, 64, 1024);
|
|
218
|
+
let queries = 0;
|
|
160
219
|
for (let i = live - 1; i >= 0; i--) {
|
|
161
220
|
age[i] += dt;
|
|
162
221
|
if (age[i] >= life[i]) {
|
|
222
|
+
if (cfg.deathEvents && events.length < eventLimit)
|
|
223
|
+
recordEvent("death", i);
|
|
163
224
|
swapRemove(i);
|
|
164
225
|
continue;
|
|
165
226
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
227
|
+
previous[i * 3] = px[i];
|
|
228
|
+
previous[i * 3 + 1] = py[i];
|
|
229
|
+
previous[i * 3 + 2] = pz[i];
|
|
230
|
+
let ax = cfg.gravity[0], ay = cfg.gravity[1], az = cfg.gravity[2];
|
|
231
|
+
queryFrom[0] = px[i];
|
|
232
|
+
queryFrom[1] = py[i];
|
|
233
|
+
queryFrom[2] = pz[i];
|
|
234
|
+
for (let f = 0; f < Math.min(8, cfg.forces?.length ?? 0); f++) {
|
|
235
|
+
const force = sampleForceField(cfg.forces[f], queryFrom, 0xffffffff, sampledForce);
|
|
236
|
+
ax += force[0];
|
|
237
|
+
ay += force[1];
|
|
238
|
+
az += force[2];
|
|
239
|
+
}
|
|
240
|
+
if (options.acceleration !== undefined) {
|
|
241
|
+
queryVelocity[0] = vx[i];
|
|
242
|
+
queryVelocity[1] = vy[i];
|
|
243
|
+
queryVelocity[2] = vz[i];
|
|
244
|
+
const force = options.acceleration(queryFrom, queryVelocity, sampledForce);
|
|
245
|
+
ax += force[0];
|
|
246
|
+
ay += force[1];
|
|
247
|
+
az += force[2];
|
|
248
|
+
}
|
|
249
|
+
vx[i] = (vx[i] + ax * dt) * damp;
|
|
250
|
+
vy[i] = (vy[i] + ay * dt) * damp;
|
|
251
|
+
vz[i] = (vz[i] + az * dt) * damp;
|
|
169
252
|
px[i] += vx[i] * dt;
|
|
170
253
|
py[i] += vy[i] * dt;
|
|
171
254
|
pz[i] += vz[i] * dt;
|
|
255
|
+
if (contact === undefined)
|
|
256
|
+
continue;
|
|
257
|
+
queryTo[0] = px[i];
|
|
258
|
+
queryTo[1] = py[i];
|
|
259
|
+
queryTo[2] = pz[i];
|
|
260
|
+
let hit = null;
|
|
261
|
+
if (options.collision !== undefined && queries < queryLimit) {
|
|
262
|
+
queries++;
|
|
263
|
+
hit = options.collision(queryFrom, queryTo);
|
|
264
|
+
}
|
|
265
|
+
if (hit === null && contact.planeY !== undefined && queryFrom[1] >= contact.planeY && py[i] <= contact.planeY && vy[i] < 0) {
|
|
266
|
+
const t = (queryFrom[1] - contact.planeY) / (queryFrom[1] - py[i]);
|
|
267
|
+
hit = { position: [queryFrom[0] + (px[i] - queryFrom[0]) * t, contact.planeY, queryFrom[2] + (pz[i] - queryFrom[2]) * t], normal: [0, 1, 0] };
|
|
268
|
+
}
|
|
269
|
+
if (hit === null)
|
|
270
|
+
continue;
|
|
271
|
+
px[i] = hit.position[0];
|
|
272
|
+
py[i] = hit.position[1];
|
|
273
|
+
pz[i] = hit.position[2];
|
|
274
|
+
if (events.length < eventLimit)
|
|
275
|
+
recordEvent("collision", i, hit.normal);
|
|
276
|
+
if (contact.response !== "bounce") {
|
|
277
|
+
swapRemove(i);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const length = Math.hypot(...hit.normal);
|
|
281
|
+
if (length === 0)
|
|
282
|
+
continue;
|
|
283
|
+
const nx = hit.normal[0] / length, ny = hit.normal[1] / length, nz = hit.normal[2] / length;
|
|
284
|
+
const dot = vx[i] * nx + vy[i] * ny + vz[i] * nz;
|
|
285
|
+
if (dot < 0) {
|
|
286
|
+
const impulse = (1 + clamp01(contact.restitution ?? 0.5)) * dot;
|
|
287
|
+
vx[i] -= impulse * nx;
|
|
288
|
+
vy[i] -= impulse * ny;
|
|
289
|
+
vz[i] -= impulse * nz;
|
|
290
|
+
}
|
|
172
291
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
292
|
+
if (running && Number.isFinite(cfg.rate) && cfg.rate > 0) {
|
|
293
|
+
accumulator = Math.min(Number.MAX_SAFE_INTEGER, accumulator + cfg.rate * dt);
|
|
294
|
+
const wanted = Math.floor(accumulator);
|
|
295
|
+
accumulator -= wanted;
|
|
296
|
+
for (let k = 0, n = Math.min(wanted, max - live); k < n; k++)
|
|
178
297
|
spawnOne();
|
|
179
|
-
}
|
|
180
298
|
}
|
|
181
299
|
notify();
|
|
182
300
|
},
|
|
183
301
|
emit(n) {
|
|
184
|
-
const wanted = Math.max(0, Math.floor(n));
|
|
302
|
+
const wanted = Number.isFinite(n) ? Math.min(max - live, Math.max(0, Math.floor(n))) : 0;
|
|
185
303
|
for (let k = 0; k < wanted; k++)
|
|
186
304
|
spawnOne();
|
|
187
305
|
notify();
|
|
188
306
|
},
|
|
307
|
+
start() { if (!running) {
|
|
308
|
+
running = true;
|
|
309
|
+
notify();
|
|
310
|
+
} },
|
|
311
|
+
stop() { if (running) {
|
|
312
|
+
running = false;
|
|
313
|
+
notify();
|
|
314
|
+
} },
|
|
315
|
+
drainEvents() { const drained = events; events = []; return drained; },
|
|
189
316
|
count() {
|
|
190
317
|
return live;
|
|
191
318
|
},
|
|
192
319
|
buffers() {
|
|
193
320
|
for (let i = 0; i < live; i++) {
|
|
194
321
|
const t = clamp01(age[i] / life[i]);
|
|
322
|
+
outVelocity[i * 3] = vx[i];
|
|
323
|
+
outVelocity[i * 3 + 1] = vy[i];
|
|
324
|
+
outVelocity[i * 3 + 2] = vz[i];
|
|
195
325
|
outPos[i * 3] = px[i];
|
|
196
326
|
outPos[i * 3 + 1] = py[i];
|
|
197
327
|
outPos[i * 3 + 2] = pz[i];
|
|
@@ -201,15 +331,21 @@ export function createParticleSystem(config = {}) {
|
|
|
201
331
|
outColor[i * 3 + 2] = b0[i] + (b1[i] - b0[i]) * t;
|
|
202
332
|
outAlpha[i] = a0[i] + (a1[i] - a0[i]) * t;
|
|
203
333
|
}
|
|
204
|
-
|
|
334
|
+
renderBuffers.count = live;
|
|
335
|
+
return renderBuffers;
|
|
205
336
|
},
|
|
206
337
|
clear() {
|
|
207
338
|
live = 0;
|
|
208
339
|
accumulator = 0;
|
|
340
|
+
events = [];
|
|
209
341
|
notify();
|
|
210
342
|
},
|
|
211
|
-
configure(patch) {
|
|
212
|
-
Object.assign(cfg, patch);
|
|
343
|
+
configure(patch, replace = false) {
|
|
344
|
+
Object.assign(cfg, replace ? emitterDefaults(patch) : patch);
|
|
345
|
+
if (patch.seed !== undefined && patch.seed !== configuredSeed) {
|
|
346
|
+
configuredSeed = patch.seed;
|
|
347
|
+
seed = randomSeedFrom(typeof patch.seed === "number" ? patch.seed : hashString(patch.seed));
|
|
348
|
+
}
|
|
213
349
|
notify();
|
|
214
350
|
},
|
|
215
351
|
subscribe(listener) {
|
|
@@ -221,12 +357,20 @@ export function createParticleSystem(config = {}) {
|
|
|
221
357
|
for (let i = 0; i < live; i++) {
|
|
222
358
|
data.push(px[i], py[i], pz[i], vx[i], vy[i], vz[i], age[i], life[i], sizeS[i], sizeE[i], r0[i], g0[i], b0[i], r1[i], g1[i], b1[i], a0[i], a1[i]);
|
|
223
359
|
}
|
|
224
|
-
return { seed: seed, accumulator, count: live, data
|
|
360
|
+
return { seed: seed, accumulator, count: live, data, running, nextId,
|
|
361
|
+
ids: Array.from(ids.subarray(0, live)), previousPositions: Array.from(previous.subarray(0, live * 3)), config: structuredClone({ ...cfg, seed: configuredSeed }) };
|
|
225
362
|
},
|
|
226
363
|
restore(snapshot) {
|
|
364
|
+
if (snapshot.config !== undefined) {
|
|
365
|
+
Object.assign(cfg, structuredClone(snapshot.config));
|
|
366
|
+
configuredSeed = snapshot.config.seed;
|
|
367
|
+
}
|
|
227
368
|
seed = randomSeedFrom(snapshot.seed);
|
|
228
369
|
accumulator = snapshot.accumulator;
|
|
229
|
-
live = Math.min(max, snapshot.count);
|
|
370
|
+
live = Math.min(max, Math.max(0, Math.floor(snapshot.count)), Math.floor(snapshot.data.length / 18));
|
|
371
|
+
running = snapshot.running ?? true;
|
|
372
|
+
nextId = snapshot.nextId ?? live + 1;
|
|
373
|
+
events = [];
|
|
230
374
|
const stride = 18;
|
|
231
375
|
for (let i = 0; i < live; i++) {
|
|
232
376
|
const o = i * stride;
|
|
@@ -248,9 +392,117 @@ export function createParticleSystem(config = {}) {
|
|
|
248
392
|
b1[i] = snapshot.data[o + 15];
|
|
249
393
|
a0[i] = snapshot.data[o + 16];
|
|
250
394
|
a1[i] = snapshot.data[o + 17];
|
|
395
|
+
ids[i] = snapshot.ids?.[i] ?? i + 1;
|
|
396
|
+
previous[i * 3] = snapshot.previousPositions?.[i * 3] ?? px[i];
|
|
397
|
+
previous[i * 3 + 1] = snapshot.previousPositions?.[i * 3 + 1] ?? py[i];
|
|
398
|
+
previous[i * 3 + 2] = snapshot.previousPositions?.[i * 3 + 2] ?? pz[i];
|
|
251
399
|
}
|
|
252
400
|
notify();
|
|
253
401
|
},
|
|
254
402
|
};
|
|
255
403
|
return system;
|
|
256
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* Validate the serializable spawn/update descriptor before authoring or loading it.
|
|
407
|
+
* @capability particle-emitter-validation validate finite spawn volumes, force fields, lifetimes, appearance curves, and bounded particle collision policies
|
|
408
|
+
*/
|
|
409
|
+
export function validateEmitterConfig(value) {
|
|
410
|
+
const errors = [];
|
|
411
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
412
|
+
return ["emitter must be an object"];
|
|
413
|
+
const config = value;
|
|
414
|
+
const finite = (v) => typeof v === "number" && Number.isFinite(v);
|
|
415
|
+
const number = (name, min, max = Infinity, integer = false) => {
|
|
416
|
+
const v = config[name];
|
|
417
|
+
if (v !== undefined && (!finite(v) || v < min || v > max || (integer && !Number.isInteger(v))))
|
|
418
|
+
errors.push(`${name} must be ${integer ? "an integer" : "finite"} in [${min}, ${max}]`);
|
|
419
|
+
};
|
|
420
|
+
const vector = (name, nonnegative = false) => {
|
|
421
|
+
const v = config[name];
|
|
422
|
+
if (v !== undefined && (!Array.isArray(v) || v.length !== 3 || v.some((n) => !finite(n) || (nonnegative && n < 0))))
|
|
423
|
+
errors.push(`${name} must contain three finite${nonnegative ? " nonnegative" : ""} numbers`);
|
|
424
|
+
};
|
|
425
|
+
number("max", 1, 65536, true);
|
|
426
|
+
number("rate", 0);
|
|
427
|
+
number("spread", 0, Math.PI);
|
|
428
|
+
number("drag", 0, 1);
|
|
429
|
+
number("colorStart", 0, 0xffffff, true);
|
|
430
|
+
number("colorEnd", 0, 0xffffff, true);
|
|
431
|
+
vector("position");
|
|
432
|
+
vector("direction");
|
|
433
|
+
vector("gravity");
|
|
434
|
+
vector("spawnJitter", true);
|
|
435
|
+
for (const [name, lower] of [["lifetime", Number.MIN_VALUE], ["speed", 0]]) {
|
|
436
|
+
const v = config[name];
|
|
437
|
+
if (v === undefined)
|
|
438
|
+
continue;
|
|
439
|
+
if (v === null || typeof v !== "object") {
|
|
440
|
+
errors.push(`${name} must be a min/max range`);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const range = v;
|
|
444
|
+
if (!finite(range.min) || !finite(range.max) || range.min < lower || range.max < range.min)
|
|
445
|
+
errors.push(`${name} must have finite ordered min/max values ${name === "lifetime" ? "above zero" : "at least zero"}`);
|
|
446
|
+
}
|
|
447
|
+
for (const name of ["size", "alpha"]) {
|
|
448
|
+
const v = config[name];
|
|
449
|
+
if (v === undefined)
|
|
450
|
+
continue;
|
|
451
|
+
if (v === null || typeof v !== "object") {
|
|
452
|
+
errors.push(`${name} must be a start/end curve`);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const curve = v;
|
|
456
|
+
if (!finite(curve.start) || !finite(curve.end) || curve.start < 0 || curve.end < 0 || (name === "alpha" && (curve.start > 1 || curve.end > 1)))
|
|
457
|
+
errors.push(`${name} must have finite nonnegative start/end values${name === "alpha" ? " at most one" : ""}`);
|
|
458
|
+
}
|
|
459
|
+
if (config.spawnShape !== undefined) {
|
|
460
|
+
const shape = config.spawnShape;
|
|
461
|
+
if (shape === null || typeof shape !== "object" || Array.isArray(shape))
|
|
462
|
+
errors.push("spawnShape must be a volume descriptor");
|
|
463
|
+
else {
|
|
464
|
+
if (shape.kind !== "disc" && shape.kind !== "sphere" && shape.kind !== "cone")
|
|
465
|
+
errors.push("spawnShape.kind must be disc, sphere, or cone");
|
|
466
|
+
if (!finite(shape.radius) || shape.radius <= 0)
|
|
467
|
+
errors.push("spawnShape.radius must be finite and positive");
|
|
468
|
+
if (shape.height !== undefined && (!finite(shape.height) || shape.height <= 0))
|
|
469
|
+
errors.push("spawnShape.height must be finite and positive");
|
|
470
|
+
if (shape.surface !== undefined && typeof shape.surface !== "boolean")
|
|
471
|
+
errors.push("spawnShape.surface must be boolean");
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (config.seed !== undefined && typeof config.seed !== "string" && !finite(config.seed))
|
|
475
|
+
errors.push("seed must be a string or finite number");
|
|
476
|
+
if (config.deathEvents !== undefined && typeof config.deathEvents !== "boolean")
|
|
477
|
+
errors.push("deathEvents must be boolean");
|
|
478
|
+
if (config.forces !== undefined) {
|
|
479
|
+
if (!Array.isArray(config.forces) || config.forces.length > 8)
|
|
480
|
+
errors.push("forces must be an array of at most eight fields");
|
|
481
|
+
else
|
|
482
|
+
config.forces.forEach((value, i) => {
|
|
483
|
+
try {
|
|
484
|
+
validateForceField(value);
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
errors.push(`forces[${i}]: ${error instanceof Error ? error.message : String(error)}`);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
if (config.collision !== undefined) {
|
|
492
|
+
const contact = config.collision;
|
|
493
|
+
if (contact === null || typeof contact !== "object" || Array.isArray(contact))
|
|
494
|
+
errors.push("collision must be a contact policy");
|
|
495
|
+
else {
|
|
496
|
+
if (contact.planeY !== undefined && !finite(contact.planeY))
|
|
497
|
+
errors.push("collision.planeY must be finite");
|
|
498
|
+
if (contact.response !== undefined && contact.response !== "kill" && contact.response !== "bounce")
|
|
499
|
+
errors.push("collision.response must be kill or bounce");
|
|
500
|
+
if (contact.restitution !== undefined && (!finite(contact.restitution) || contact.restitution < 0 || contact.restitution > 1))
|
|
501
|
+
errors.push("collision.restitution must be in [0, 1]");
|
|
502
|
+
for (const [name, cap] of [["maxQueries", 4096], ["maxEvents", 1024]])
|
|
503
|
+
if (contact[name] !== undefined && (!finite(contact[name]) || !Number.isInteger(contact[name]) || contact[name] < 0 || contact[name] > cap))
|
|
504
|
+
errors.push(`collision.${name} must be an integer in [0, ${cap}]`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return errors;
|
|
508
|
+
}
|
|
@@ -11,7 +11,7 @@ export interface CancelSignal {
|
|
|
11
11
|
readonly cancelled: boolean;
|
|
12
12
|
}
|
|
13
13
|
export interface AssetStreamingOptions {
|
|
14
|
-
/** Async loader.
|
|
14
|
+
/** Async loader. Honor `signal.cancelled` and always settle; cancelled successful loads are released through `unload`. */
|
|
15
15
|
readonly load: (assetId: string, signal: CancelSignal) => Promise<AssetLoadResult>;
|
|
16
16
|
/** Release GPU/CPU resources for an asset. */
|
|
17
17
|
readonly unload?: (assetId: string) => void;
|
|
@@ -28,6 +28,7 @@ export interface AssetRecord {
|
|
|
28
28
|
lastActiveMs: number;
|
|
29
29
|
value: unknown;
|
|
30
30
|
}
|
|
31
|
+
/** Current demand/residency gauges and cumulative unload/cancellation counters; clear resets totals. */
|
|
31
32
|
export interface StreamingStats {
|
|
32
33
|
queued: number;
|
|
33
34
|
loading: number;
|
|
@@ -36,7 +37,18 @@ export interface StreamingStats {
|
|
|
36
37
|
errored: number;
|
|
37
38
|
inFlight: number;
|
|
38
39
|
cancelled: number;
|
|
40
|
+
/** Known resident bytes; missing or invalid loader sizes count as zero. */
|
|
39
41
|
bytes: number;
|
|
42
|
+
/** Current tracked records, including inactive history until forget or clear. */
|
|
43
|
+
records: number;
|
|
44
|
+
/** Known bytes protected by pins or active references. */
|
|
45
|
+
protectedBytes: number;
|
|
46
|
+
/** Loaded assets whose resident size is unknown. */
|
|
47
|
+
unknownSizeLoaded: number;
|
|
48
|
+
/** Known resident bytes above the configured target, including protected bytes. */
|
|
49
|
+
overBudgetBytes: number;
|
|
50
|
+
/** Cumulative resources evicted by resident-byte pressure since clear. */
|
|
51
|
+
budgetEvicted: number;
|
|
40
52
|
}
|
|
41
53
|
export interface AssetStreamingSystem {
|
|
42
54
|
/** Demand an asset (near/in view). Deduplicated — an already loaded/loading/queued asset is never re-fetched concurrently. */
|
|
@@ -49,18 +61,24 @@ export interface AssetStreamingSystem {
|
|
|
49
61
|
unpin(assetId: string): void;
|
|
50
62
|
/** Refresh the "last needed" timestamp used by the grace-period unload. */
|
|
51
63
|
markActive(assetId: string): void;
|
|
52
|
-
/** Advance one frame:
|
|
64
|
+
/** Advance one frame: bounded load starts and unloads; resident-byte pressure precedes idle unloading. */
|
|
53
65
|
tick(dt: number): void;
|
|
54
|
-
/** Cancel
|
|
66
|
+
/** Cancel demand. Unresolved loads still occupy concurrency slots and block same-id retries until settlement. */
|
|
55
67
|
cancel(assetId: string): void;
|
|
68
|
+
/** Merge policy. Zero concurrency pauses starts; resident-byte target/order changes apply on the next tick. */
|
|
69
|
+
retune(settings: Partial<StreamingSettings>): void;
|
|
56
70
|
stateOf(assetId: string): AssetLoadState | undefined;
|
|
57
71
|
isLoaded(assetId: string): boolean;
|
|
58
72
|
record(assetId: string): AssetRecord | undefined;
|
|
73
|
+
/** Drop inactive unloaded/error history. Returns false for missing, pinned, retained, queued, loaded or unresolved assets. Old record handles remain detached; future demand creates a new record. */
|
|
74
|
+
forget(assetId: string): boolean;
|
|
59
75
|
stats(): StreamingStats;
|
|
60
76
|
/** Merge asset counters into a per-frame VisibilityStats. */
|
|
61
77
|
applyTo(stats: VisibilityStats): void;
|
|
62
78
|
/** Resolve once every in-flight load settles — for deterministic tests. */
|
|
63
79
|
settle(): Promise<void>;
|
|
80
|
+
/** Release loaded resources and cancel demand; outstanding loads remain tracked until settlement. */
|
|
64
81
|
clear(): void;
|
|
65
82
|
}
|
|
83
|
+
/** Stream shared resources with bounded load concurrency, per-tick resident-byte eviction, and explicit inactive-history cleanup. */
|
|
66
84
|
export declare function createAssetStreamingSystem(options: AssetStreamingOptions): AssetStreamingSystem;
|