@call-me-sensei/toonlab 0.2.0 → 0.3.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.
Files changed (205) hide show
  1. package/AGENTS.md +127 -4
  2. package/ATTRIBUTION.md +41 -3
  3. package/README.md +234 -34
  4. package/docs/characters.md +144 -0
  5. package/docs/debug-panel.md +126 -0
  6. package/docs/docs.css +589 -0
  7. package/docs/environment.md +186 -0
  8. package/docs/getting-started.md +180 -0
  9. package/docs/index.html +14 -0
  10. package/docs/lab-architecture.md +100 -0
  11. package/docs/lighting.md +825 -0
  12. package/docs/main.jsx +739 -0
  13. package/docs/mcp.md +97 -0
  14. package/docs/post-processing.md +98 -0
  15. package/docs/settings-reference.md +1878 -0
  16. package/docs/shader-constants.md +77 -0
  17. package/docs/sky.md +182 -0
  18. package/docs/style-labs.md +309 -0
  19. package/docs/texture-lab.md +135 -0
  20. package/docs/toon-shading.md +183 -0
  21. package/docs/tsl-conventions.md +167 -0
  22. package/docs/vegetation-sky.md +275 -0
  23. package/docs/water.md +430 -0
  24. package/docs/weather.md +200 -0
  25. package/docs/world-scale.md +111 -0
  26. package/mcp/server.mjs +610 -0
  27. package/mcp/style-lab-tools.mjs +371 -0
  28. package/mcp/vite-plugin.mjs +174 -0
  29. package/mcp/workspace.mjs +397 -0
  30. package/package.json +64 -5
  31. package/src/ambientfx/INTEGRATION.md +164 -0
  32. package/src/ambientfx/ambientFxPresets.js +83 -0
  33. package/src/ambientfx/ambientFxSettings.js +368 -0
  34. package/src/ambientfx/emitters.js +169 -0
  35. package/src/ambientfx/index.js +5 -0
  36. package/src/ambientfx/particleBackbone.js +493 -0
  37. package/src/ambientfx/stylizedAmbientFx.js +450 -0
  38. package/src/assetlib/ambientcg.js +117 -0
  39. package/src/assetlib/assetRef.js +91 -0
  40. package/src/assetlib/importedEntry.js +40 -0
  41. package/src/assetlib/index.js +23 -0
  42. package/src/assetlib/kaykit.js +192 -0
  43. package/src/assetlib/kaykitStaticIndex.js +700 -0
  44. package/src/assetlib/loadImported.js +143 -0
  45. package/src/assetlib/opensource3d.js +113 -0
  46. package/src/assetlib/polyhaven.js +182 -0
  47. package/src/assetlib/polypizza.js +115 -0
  48. package/src/assetlib/smithsonian.js +214 -0
  49. package/src/assetlib/sources.js +279 -0
  50. package/src/assetlib/zip.js +58 -0
  51. package/src/biome/biomeGenerator.js +385 -0
  52. package/src/biome/biomeRuntime.js +299 -0
  53. package/src/biome/index.js +2 -0
  54. package/src/buildinggen/buildingAsset.js +44 -0
  55. package/src/buildinggen/buildingGrammar.js +311 -0
  56. package/src/buildinggen/buildingMesh.js +451 -0
  57. package/src/buildinggen/buildingPresets.js +46 -0
  58. package/src/buildinggen/buildingRecipe.js +100 -0
  59. package/src/buildinggen/buildingSettings.js +238 -0
  60. package/src/buildinggen/index.js +6 -0
  61. package/src/camera/cameraDirector.js +157 -0
  62. package/src/camera/cameraGenerator.js +367 -0
  63. package/src/camera/cameraRig.js +570 -0
  64. package/src/camera/cameraSettings.js +236 -0
  65. package/src/camera/index.js +7 -0
  66. package/src/catalog/builtinEntries.js +245 -0
  67. package/src/catalog/catalog.js +210 -0
  68. package/src/catalog/index.js +3 -0
  69. package/src/catalog/manifest.js +84 -0
  70. package/src/core/generation.js +529 -0
  71. package/src/environment/environmentRigs.js +21 -1
  72. package/src/environment/environmentSettings.js +4 -0
  73. package/src/environment/environmentSunShadowPass.js +8 -0
  74. package/src/fauna/INTEGRATION.md +174 -0
  75. package/src/fauna/boids.js +861 -0
  76. package/src/fauna/faunaBodies.js +492 -0
  77. package/src/fauna/faunaPresets.js +52 -0
  78. package/src/fauna/faunaSettings.js +525 -0
  79. package/src/fauna/index.js +5 -0
  80. package/src/fauna/stylizedFauna.js +395 -0
  81. package/src/game-feel/gameFeelGenerator.js +402 -0
  82. package/src/game-feel/gameFeelRuntime.js +549 -0
  83. package/src/game-feel/index.js +2 -0
  84. package/src/index.js +17 -6
  85. package/src/lighting/colorIntensity.js +177 -0
  86. package/src/lighting/index.js +161 -0
  87. package/src/lighting/lightDescriptors.js +249 -0
  88. package/src/lighting/lightingCapabilities.js +79 -0
  89. package/src/lighting/lightingDocuments.js +247 -0
  90. package/src/lighting/lightingFixtures.js +446 -0
  91. package/src/lighting/lightingGenerator.js +449 -0
  92. package/src/lighting/lightingPresets.js +319 -0
  93. package/src/lighting/lightingRuntime.js +723 -0
  94. package/src/lighting/lightingStyle.js +386 -0
  95. package/src/lighting/lightingSystem.js +774 -0
  96. package/src/lighting/unrealExport.js +186 -0
  97. package/src/lighting/utils.js +87 -0
  98. package/src/motion/index.js +5 -0
  99. package/src/motion/motionClip.js +441 -0
  100. package/src/motion/motionController.js +628 -0
  101. package/src/motion/motionDocuments.js +225 -0
  102. package/src/motion/motionGraph.js +307 -0
  103. package/src/motion/motionSettings.js +222 -0
  104. package/src/pathgen/index.js +7 -0
  105. package/src/pathgen/pathBridge.js +232 -0
  106. package/src/pathgen/pathPresets.js +35 -0
  107. package/src/pathgen/pathRibbon.js +410 -0
  108. package/src/pathgen/pathRouter.js +380 -0
  109. package/src/pathgen/pathSettings.js +335 -0
  110. package/src/pathgen/pathTextures.js +123 -0
  111. package/src/pathgen/stylizedPaths.js +453 -0
  112. package/src/post/index.js +1 -0
  113. package/src/post/postGenerator.js +177 -0
  114. package/src/post/postProcessing.js +41 -0
  115. package/src/propgen/generatorsWave1.js +379 -0
  116. package/src/propgen/generatorsWave2.js +462 -0
  117. package/src/propgen/index.js +5 -0
  118. package/src/propgen/propAsset.js +323 -0
  119. package/src/propgen/propParts.js +170 -0
  120. package/src/propgen/propPlacement.js +459 -0
  121. package/src/propgen/propPresets.js +82 -0
  122. package/src/propgen/propSettings.js +395 -0
  123. package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
  124. package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
  125. package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
  126. package/src/shaders-tsl/chunks/water-waves.js +90 -11
  127. package/src/shaders-tsl/environment.js +15 -1
  128. package/src/shaders-tsl/flower.js +279 -30
  129. package/src/shaders-tsl/grass.js +60 -33
  130. package/src/shaders-tsl/sky.js +125 -31
  131. package/src/shaders-tsl/tree-leaf.js +61 -25
  132. package/src/shaders-tsl/water-breaker.js +7 -4
  133. package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
  134. package/src/shaders-tsl/water.js +439 -49
  135. package/src/shaders-tsl/woody-surface.js +154 -0
  136. package/src/sky/sceneOverrideLayers.js +10 -0
  137. package/src/sky/skyQuality.js +26 -0
  138. package/src/sky/stylizedSky.js +753 -45
  139. package/src/soundscape/index.js +4 -0
  140. package/src/soundscape/soundscapeGenerator.js +179 -0
  141. package/src/soundscape/soundscapeRuntime.js +806 -0
  142. package/src/soundscape/soundscapeSettings.js +292 -0
  143. package/src/styles/index.js +13 -0
  144. package/src/styles/styleBundle.js +325 -0
  145. package/src/stylizedTerrain.js +32 -2
  146. package/src/stylizedWorld.js +423 -20
  147. package/src/texgen/evaluateTexture.js +675 -0
  148. package/src/texgen/index.js +60 -0
  149. package/src/texgen/noise2.js +210 -0
  150. package/src/texgen/textureAi.js +436 -0
  151. package/src/texgen/textureGenerators.js +516 -0
  152. package/src/texgen/texturePresets.js +490 -0
  153. package/src/texgen/textureSettings.js +342 -0
  154. package/src/texgen/textureThree.js +59 -0
  155. package/src/vegetation/flowerSpecies.js +15 -3
  156. package/src/vegetation/grassPalettes.js +153 -0
  157. package/src/vegetation/index.js +6 -0
  158. package/src/vegetation/stylizedBush.js +2 -0
  159. package/src/vegetation/stylizedFlower.js +82 -0
  160. package/src/vegetation/stylizedFlowers.js +48 -7
  161. package/src/vegetation/stylizedForest.js +29 -1
  162. package/src/vegetation/stylizedGrass.js +291 -56
  163. package/src/vegetation/stylizedTree.js +38 -2
  164. package/src/vegetation/stylizedTreeFoliage.js +2 -1
  165. package/src/vegetation/vegetationShaders.js +1110 -0
  166. package/src/vfxgen/INTEGRATION.md +145 -0
  167. package/src/vfxgen/core/burstBackbone.js +380 -0
  168. package/src/vfxgen/core/projectileCore.js +92 -0
  169. package/src/vfxgen/core/spriteShapes.js +98 -0
  170. package/src/vfxgen/core/trailRibbon.js +272 -0
  171. package/src/vfxgen/core/vfxRandom.js +29 -0
  172. package/src/vfxgen/effects/emitHelpers.js +37 -0
  173. package/src/vfxgen/effects/magicEffects.js +162 -0
  174. package/src/vfxgen/effects/movementEffects.js +87 -0
  175. package/src/vfxgen/effects/weaponEffects.js +118 -0
  176. package/src/vfxgen/index.js +18 -0
  177. package/src/vfxgen/moves/moveController.js +146 -0
  178. package/src/vfxgen/moves/moveLibrary.js +335 -0
  179. package/src/vfxgen/vfxPresets.js +98 -0
  180. package/src/vfxgen/vfxSettings.js +384 -0
  181. package/src/vfxgen/vfxSystem.js +449 -0
  182. package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
  183. package/src/villagegen/index.js +4 -0
  184. package/src/villagegen/stylizedVillage.js +490 -0
  185. package/src/villagegen/villageArchetypes.js +160 -0
  186. package/src/villagegen/villageNames.js +40 -0
  187. package/src/villagegen/villageSites.js +105 -0
  188. package/src/water/sceneOverrideLayers.js +23 -0
  189. package/src/water/water.js +5 -0
  190. package/src/water/waterBreakerSystem.js +15 -1
  191. package/src/water/waterCurrentField.js +447 -0
  192. package/src/water/waterMaterial.js +12 -0
  193. package/src/water/waterNearshorePhase.js +320 -0
  194. package/src/water/waterScenePasses.js +83 -30
  195. package/src/water/waterSettings.js +325 -13
  196. package/src/water/waterShoreMaterial.js +322 -0
  197. package/src/water/waterShoreStateField.js +605 -0
  198. package/src/water/waterSurface.js +797 -28
  199. package/src/weather/index.js +6 -0
  200. package/src/weather/weatherPrecipitation.js +221 -0
  201. package/src/weather/weatherPresets.js +258 -0
  202. package/src/weather/weatherSettings.js +269 -0
  203. package/src/weather/weatherSystem.js +871 -0
  204. package/src/worldMinimap.js +62 -0
  205. package/src/worldPresets.js +4 -1
@@ -0,0 +1,570 @@
1
+ import * as THREE from 'three';
2
+
3
+ import { createSeededRandom, hashSeed } from '../core/generation.js';
4
+ import { createCameraSettings } from './cameraSettings.js';
5
+ import { DEFAULT_CAMERA_OPERATORS, normalizeCameraOperators } from './cameraGenerator.js';
6
+
7
+ const operatorFactories = new Map();
8
+ const WORLD_UP = new THREE.Vector3(0, 1, 0);
9
+ const IDENTITY_QUATERNION = new THREE.Quaternion();
10
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
11
+ const finite = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback;
12
+ const dampAlpha = (rate, delta) => Number(rate) <= 0 ? 1 : 1 - Math.exp(-Number(rate) * delta);
13
+
14
+ function copyVector(target, value, fallback = [0, 0, 0]) {
15
+ if (value?.isVector3) return target.copy(value);
16
+ const source = Array.isArray(value) ? value : fallback;
17
+ return target.set(finite(source[0], fallback[0]), finite(source[1], fallback[1]), finite(source[2], fallback[2]));
18
+ }
19
+
20
+ function operatorSettings(context, definition, group, cache) {
21
+ Object.assign(cache, context.settings[group] ?? {}, definition.settings ?? {});
22
+ return cache;
23
+ }
24
+
25
+ /** Registers a runtime camera operator. Serialized recipes refer to its type. */
26
+ export function registerCameraOperator(type, factory, { overwrite = false } = {}) {
27
+ const id = String(type ?? '').trim();
28
+ if (!id) throw new Error('Camera operator type is required.');
29
+ if (typeof factory !== 'function') throw new Error(`Camera operator "${id}" factory must be a function.`);
30
+ if (!overwrite && operatorFactories.has(id)) throw new Error(`Camera operator "${id}" is already registered.`);
31
+ operatorFactories.set(id, factory);
32
+ return id;
33
+ }
34
+
35
+ export function getCameraOperatorOptions() {
36
+ return [...operatorFactories.keys()];
37
+ }
38
+
39
+ function followFactory(definition) {
40
+ const merged = {};
41
+ const localOffset = new THREE.Vector3();
42
+ const localAim = new THREE.Vector3();
43
+ const yaw = new THREE.Euler(0, 0, 0, 'YXZ');
44
+ const rotation = new THREE.Quaternion();
45
+ return {
46
+ update(context) {
47
+ const settings = operatorSettings(context, definition, 'follow', merged);
48
+ copyVector(localOffset, settings.offset, [0, 2.8, 6.4]);
49
+ copyVector(localAim, settings.targetOffset, [0, 1.45, 0]);
50
+ if (settings.yawOnly) {
51
+ yaw.setFromQuaternion(context.targetQuaternion, 'YXZ');
52
+ rotation.setFromAxisAngle(context.up, yaw.y);
53
+ } else {
54
+ rotation.copy(context.targetQuaternion);
55
+ }
56
+ localOffset.applyQuaternion(rotation);
57
+ localAim.applyQuaternion(rotation);
58
+ context.desiredPosition.copy(context.targetPosition).add(localOffset);
59
+ context.desiredLookAt.copy(context.targetPosition).add(localAim);
60
+ },
61
+ };
62
+ }
63
+
64
+ function lookAheadFactory(definition) {
65
+ const merged = {};
66
+ const ahead = new THREE.Vector3();
67
+ return {
68
+ update(context) {
69
+ const settings = operatorSettings(context, definition, 'lookAhead', merged);
70
+ if (!settings.enabled) return;
71
+ ahead.copy(context.targetVelocity);
72
+ if (settings.horizontalOnly) ahead.addScaledVector(context.up, -ahead.dot(context.up));
73
+ ahead.multiplyScalar(settings.time);
74
+ const maxDistance = Math.max(0, settings.maxDistance);
75
+ if (ahead.lengthSq() > maxDistance * maxDistance) ahead.setLength(maxDistance);
76
+ context.desiredLookAt.addScaledVector(ahead, finite(definition.settings?.aimInfluence, 1));
77
+ context.desiredPosition.addScaledVector(ahead, finite(definition.settings?.positionInfluence, 0.28));
78
+ },
79
+ };
80
+ }
81
+
82
+ function framingFactory(definition) {
83
+ const merged = {};
84
+ const direction = new THREE.Vector3();
85
+ const right = new THREE.Vector3();
86
+ return {
87
+ update(context) {
88
+ const settings = operatorSettings(context, definition, 'framing', merged);
89
+ direction.subVectors(context.desiredLookAt, context.desiredPosition);
90
+ const distance = Math.max(direction.length(), 0.001);
91
+ direction.multiplyScalar(1 / distance);
92
+ right.crossVectors(direction, context.up);
93
+ if (right.lengthSq() < 1e-8) right.set(1, 0, 0);
94
+ else right.normalize();
95
+ const horizontal = (0.5 - settings.screenX) * distance * settings.horizontalScale;
96
+ const vertical = (settings.screenY - 0.5) * distance * settings.verticalScale;
97
+ context.desiredLookAt.addScaledVector(right, horizontal).addScaledVector(context.up, vertical);
98
+ },
99
+ };
100
+ }
101
+
102
+ function collisionFactory(definition, rig) {
103
+ const merged = {};
104
+ const ray = new THREE.Vector3();
105
+ const resolved = new THREE.Vector3();
106
+
107
+ function readResult(result, from, direction, desiredDistance, settings) {
108
+ if (result == null || result === false) return false;
109
+ if (typeof result === 'number') {
110
+ resolved.copy(from).addScaledVector(direction, clamp(result - settings.padding, settings.minimumDistance, desiredDistance));
111
+ return true;
112
+ }
113
+ const position = result?.position ?? result?.point ?? result;
114
+ if (position?.isVector3 || Array.isArray(position)) {
115
+ copyVector(resolved, position);
116
+ const distance = resolved.distanceTo(from);
117
+ resolved.copy(from).addScaledVector(direction, clamp(distance - settings.padding, settings.minimumDistance, desiredDistance));
118
+ return result?.hit !== false;
119
+ }
120
+ if (Number.isFinite(Number(result?.distance))) {
121
+ resolved.copy(from).addScaledVector(direction, clamp(Number(result.distance) - settings.padding, settings.minimumDistance, desiredDistance));
122
+ return result.hit !== false;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ return {
128
+ reset(context) {
129
+ context.collisionPosition.copy(context.desiredPosition);
130
+ context.collisionInitialized = false;
131
+ },
132
+ update(context) {
133
+ const settings = operatorSettings(context, definition, 'collision', merged);
134
+ if (!context.collisionInitialized) {
135
+ context.collisionPosition.copy(context.desiredPosition);
136
+ context.collisionInitialized = true;
137
+ }
138
+ let hit = false;
139
+ ray.subVectors(context.desiredPosition, context.desiredLookAt);
140
+ const desiredDistance = Math.max(ray.length(), 0.001);
141
+ ray.multiplyScalar(1 / desiredDistance);
142
+ if (settings.enabled && typeof context.collisionQuery === 'function') {
143
+ context.counters.collisionQueries += 1;
144
+ const result = context.collisionQuery({
145
+ camera: context.camera,
146
+ from: context.desiredLookAt,
147
+ padding: settings.padding,
148
+ radius: settings.radius,
149
+ rig,
150
+ target: context.target,
151
+ to: context.desiredPosition,
152
+ });
153
+ hit = readResult(result, context.desiredLookAt, ray, desiredDistance, settings);
154
+ }
155
+ if (hit) {
156
+ context.counters.collisionHits += 1;
157
+ context.collisionPosition.copy(resolved);
158
+ } else {
159
+ context.collisionPosition.lerp(
160
+ context.desiredPosition,
161
+ dampAlpha(settings.recoveryDamping, context.delta),
162
+ );
163
+ }
164
+ context.desiredPosition.copy(context.collisionPosition);
165
+ context.collisionHit = hit;
166
+ },
167
+ };
168
+ }
169
+
170
+ function dampingFactory(definition) {
171
+ const merged = {};
172
+ return {
173
+ update(context) {
174
+ const settings = operatorSettings(context, definition, 'damping', merged);
175
+ if (!context.initialized || context.teleported) {
176
+ context.currentPosition.copy(context.desiredPosition);
177
+ context.currentLookAt.copy(context.desiredLookAt);
178
+ } else {
179
+ context.currentPosition.lerp(context.desiredPosition, dampAlpha(settings.position, context.delta));
180
+ context.currentLookAt.lerp(context.desiredLookAt, dampAlpha(settings.aim, context.delta));
181
+ }
182
+ context.outputPosition.copy(context.currentPosition);
183
+ context.outputLookAt.copy(context.currentLookAt);
184
+ },
185
+ };
186
+ }
187
+
188
+ function lensFactory(definition) {
189
+ const merged = {};
190
+ return {
191
+ update(context) {
192
+ const lens = operatorSettings(context, definition, 'lens', merged);
193
+ const damping = context.settings.damping;
194
+ const speedAmount = clamp(context.targetVelocity.length() / Math.max(lens.speedReference, 0.001), 0, 1);
195
+ context.desiredFov = clamp(lens.fov + lens.speedFov * speedAmount, 5, 150);
196
+ if (!context.initialized || context.teleported) context.currentFov = context.desiredFov;
197
+ else context.currentFov += (context.desiredFov - context.currentFov) * dampAlpha(damping.lens, context.delta);
198
+ context.outputFov = context.currentFov;
199
+ context.outputNear = Math.min(lens.near, lens.far - 0.001);
200
+ context.outputFar = Math.max(lens.far, lens.near + 0.001);
201
+ },
202
+ };
203
+ }
204
+
205
+ function noiseFactory(definition) {
206
+ const merged = {};
207
+ let elapsed = 0;
208
+ let bandSignature = '';
209
+ let bands = [];
210
+
211
+ function prepare(settings) {
212
+ const octaveCount = Math.max(1, Math.round(finite(settings.octaves, 1)));
213
+ const signature = `${hashSeed(settings.seed)}:${octaveCount}`;
214
+ if (signature === bandSignature) return;
215
+ bandSignature = signature;
216
+ const random = createSeededRandom(settings.seed, `camera-noise:${definition.id}`);
217
+ bands = Array.from({ length: octaveCount }, () => ({
218
+ phases: Array.from({ length: 6 }, () => random.float(0, Math.PI * 2)),
219
+ rate: random.float(0.92, 1.08),
220
+ }));
221
+ }
222
+
223
+ function sample(settings, channel) {
224
+ let amplitude = 1;
225
+ let frequency = settings.frequency;
226
+ let totalAmplitude = 0;
227
+ let value = 0;
228
+ for (const band of bands) {
229
+ value += Math.sin(elapsed * frequency * band.rate * Math.PI * 2 + band.phases[channel]) * amplitude;
230
+ totalAmplitude += amplitude;
231
+ amplitude *= settings.persistence;
232
+ frequency *= settings.lacunarity;
233
+ }
234
+ return totalAmplitude > 0 ? value / totalAmplitude : 0;
235
+ }
236
+
237
+ return {
238
+ reset() {
239
+ elapsed = 0;
240
+ },
241
+ update(context) {
242
+ const settings = operatorSettings(context, definition, 'noise', merged);
243
+ elapsed += context.delta;
244
+ if (!settings.enabled) return;
245
+ prepare(settings);
246
+ context.outputPosition.x += sample(settings, 0) * settings.positionAmplitude;
247
+ context.outputPosition.y += sample(settings, 1) * settings.positionAmplitude * 0.65;
248
+ context.outputPosition.z += sample(settings, 2) * settings.positionAmplitude;
249
+ context.outputRotation.x += sample(settings, 3) * settings.rotationAmplitude * 0.7;
250
+ context.outputRotation.y += sample(settings, 4) * settings.rotationAmplitude;
251
+ context.outputRotation.z += sample(settings, 5) * settings.rotationAmplitude * 0.65;
252
+ },
253
+ };
254
+ }
255
+
256
+ function impulseFactory(definition) {
257
+ const merged = {};
258
+ return {
259
+ update(context) {
260
+ const settings = operatorSettings(context, definition, 'impulse', merged);
261
+ context.impulsePosition.set(0, 0, 0);
262
+ context.impulseRotation.set(0, 0, 0);
263
+ for (let index = context.impulses.length - 1; index >= 0; index -= 1) {
264
+ const impulse = context.impulses[index];
265
+ impulse.elapsed += context.delta;
266
+ if (impulse.elapsed >= impulse.duration) {
267
+ context.impulses.splice(index, 1);
268
+ continue;
269
+ }
270
+ const envelope = Math.exp(-impulse.decay * impulse.elapsed) * (1 - impulse.elapsed / impulse.duration);
271
+ const wave = impulse.frequency > 0 ? Math.cos(impulse.phase + impulse.elapsed * impulse.frequency * Math.PI * 2) : 1;
272
+ context.impulsePosition.addScaledVector(impulse.position, envelope * wave * settings.positionScale);
273
+ context.impulseRotation.addScaledVector(impulse.rotation, envelope * wave * settings.rotationScale);
274
+ }
275
+ if (context.impulsePosition.lengthSq() > settings.maxPosition ** 2) context.impulsePosition.setLength(settings.maxPosition);
276
+ const maxRotation = Math.max(Math.abs(context.impulseRotation.x), Math.abs(context.impulseRotation.y), Math.abs(context.impulseRotation.z));
277
+ if (maxRotation > settings.maxRotation && maxRotation > 0) context.impulseRotation.multiplyScalar(settings.maxRotation / maxRotation);
278
+ context.outputPosition.add(context.impulsePosition);
279
+ context.outputRotation.copy(context.impulseRotation);
280
+ },
281
+ };
282
+ }
283
+
284
+ registerCameraOperator('follow', followFactory);
285
+ registerCameraOperator('lookAhead', lookAheadFactory);
286
+ registerCameraOperator('framing', framingFactory);
287
+ registerCameraOperator('collision', collisionFactory);
288
+ registerCameraOperator('damping', dampingFactory);
289
+ registerCameraOperator('lens', lensFactory);
290
+ registerCameraOperator('noise', noiseFactory);
291
+ registerCameraOperator('impulse', impulseFactory);
292
+
293
+ function resolveTarget(source, position, quaternion) {
294
+ const target = typeof source === 'function' ? source() : source;
295
+ quaternion.identity();
296
+ if (target?.isObject3D) {
297
+ target.updateWorldMatrix(true, false);
298
+ target.getWorldPosition(position);
299
+ target.getWorldQuaternion(quaternion);
300
+ } else if (target?.position?.isVector3) {
301
+ position.copy(target.position);
302
+ if (target.quaternion?.isQuaternion) quaternion.copy(target.quaternion);
303
+ } else if (target?.isVector3 || Array.isArray(target)) {
304
+ copyVector(position, target);
305
+ } else {
306
+ position.set(0, 0, 0);
307
+ }
308
+ return target;
309
+ }
310
+
311
+ /**
312
+ * Creates a vanilla Three.js camera rig. Operator factories are open-ended;
313
+ * pass `operatorFactories` for local modules or call registerCameraOperator().
314
+ */
315
+ export function createCameraRig({
316
+ camera,
317
+ collisionQuery = null,
318
+ operatorFactories: localFactories = {},
319
+ operators = null,
320
+ preset = null,
321
+ settings = null,
322
+ target = null,
323
+ up = WORLD_UP,
324
+ } = {}) {
325
+ if (!camera?.isCamera) throw new Error('createCameraRig requires a Three.js Camera.');
326
+ let disposed = false;
327
+ let targetSource = target;
328
+ let rigSettings = createCameraSettings(settings ?? preset?.settings);
329
+ let query = collisionQuery;
330
+ let definitions = normalizeCameraOperators(operators ?? preset?.operators ?? DEFAULT_CAMERA_OPERATORS);
331
+ let instances = [];
332
+ let impulseSerial = 0;
333
+ const lookMatrix = new THREE.Matrix4();
334
+ const impulseQuaternion = new THREE.Quaternion();
335
+ const impulseEuler = new THREE.Euler(0, 0, 0, 'YXZ');
336
+ const previousTarget = new THREE.Vector3();
337
+ const measuredVelocity = new THREE.Vector3();
338
+ const sampleState = {
339
+ far: rigSettings.lens.far,
340
+ fov: rigSettings.lens.fov,
341
+ lookAt: [0, 0, 0],
342
+ near: rigSettings.lens.near,
343
+ position: [camera.position.x, camera.position.y, camera.position.z],
344
+ quaternion: [camera.quaternion.x, camera.quaternion.y, camera.quaternion.z, camera.quaternion.w],
345
+ };
346
+ const state = {
347
+ camera,
348
+ collisionHit: false,
349
+ collisionInitialized: false,
350
+ collisionPosition: new THREE.Vector3(),
351
+ collisionQuery: query,
352
+ counters: { collisionHits: 0, collisionQueries: 0, updates: 0 },
353
+ currentFov: finite(camera.fov, rigSettings.lens.fov),
354
+ currentLookAt: new THREE.Vector3(),
355
+ currentPosition: camera.position.clone(),
356
+ delta: 0,
357
+ desiredFov: rigSettings.lens.fov,
358
+ desiredLookAt: new THREE.Vector3(),
359
+ desiredPosition: camera.position.clone(),
360
+ impulsePosition: new THREE.Vector3(),
361
+ impulseRotation: new THREE.Vector3(),
362
+ impulses: [],
363
+ initialized: false,
364
+ outputFar: rigSettings.lens.far,
365
+ outputFov: rigSettings.lens.fov,
366
+ outputLookAt: new THREE.Vector3(),
367
+ outputNear: rigSettings.lens.near,
368
+ outputPosition: camera.position.clone(),
369
+ outputQuaternion: camera.quaternion.clone(),
370
+ outputRotation: new THREE.Vector3(),
371
+ settings: rigSettings,
372
+ target: null,
373
+ targetPosition: new THREE.Vector3(),
374
+ targetQuaternion: new THREE.Quaternion(),
375
+ targetVelocity: new THREE.Vector3(),
376
+ teleported: false,
377
+ up: new THREE.Vector3().copy(up?.isVector3 ? up : WORLD_UP).normalize(),
378
+ };
379
+
380
+ const rig = {
381
+ camera,
382
+ root: camera,
383
+ addImpulse(options = {}) {
384
+ if (disposed) return null;
385
+ const source = typeof options === 'number' ? { position: [0, options, 0] } : options;
386
+ const seed = hashSeed(source.seed ?? ++impulseSerial);
387
+ const random = createSeededRandom(seed, 'camera-impulse');
388
+ const position = copyVector(new THREE.Vector3(), source.position ?? source.translation, [0, 0, 0]);
389
+ const rotation = copyVector(new THREE.Vector3(), source.rotation, [0, 0, 0]);
390
+ if (source.position === undefined && source.translation === undefined && Number.isFinite(Number(source.power))) {
391
+ position.set(random.float(-1, 1), random.float(-0.35, 1), random.float(-1, 1)).normalize().multiplyScalar(Number(source.power) * 0.12);
392
+ }
393
+ if (source.rotation === undefined && Number.isFinite(Number(source.power))) {
394
+ rotation.set(random.float(-1, 1), random.float(-1, 1), random.float(-0.4, 0.4)).multiplyScalar(Number(source.power) * 0.025);
395
+ }
396
+ const impulse = {
397
+ decay: Math.max(0.01, finite(source.decay, rigSettings.impulse.decay)),
398
+ duration: Math.max(1 / 1000, finite(source.duration, 0.55)),
399
+ elapsed: 0,
400
+ frequency: Math.max(0, finite(source.frequency, 13)),
401
+ id: impulseSerial,
402
+ phase: finite(source.phase, random.float(0, Math.PI * 2)),
403
+ position,
404
+ rotation,
405
+ seed,
406
+ };
407
+ state.impulses.push(impulse);
408
+ return { id: impulse.id, stop: () => { impulse.duration = impulse.elapsed; } };
409
+ },
410
+ applyState(sample) {
411
+ if (disposed || !sample) return;
412
+ copyVector(camera.position, sample.position, [0, 0, 0]);
413
+ if (sample.quaternion?.isQuaternion) camera.quaternion.copy(sample.quaternion);
414
+ else if (Array.isArray(sample.quaternion)) camera.quaternion.fromArray(sample.quaternion);
415
+ if (camera.isPerspectiveCamera) {
416
+ camera.fov = finite(sample.fov, camera.fov);
417
+ camera.near = finite(sample.near, camera.near);
418
+ camera.far = finite(sample.far, camera.far);
419
+ camera.updateProjectionMatrix();
420
+ }
421
+ camera.updateMatrixWorld();
422
+ },
423
+ dispose() {
424
+ if (disposed) return;
425
+ disposed = true;
426
+ for (const entry of instances) entry.instance.dispose?.();
427
+ instances = [];
428
+ state.impulses.length = 0;
429
+ query = null;
430
+ targetSource = null;
431
+ },
432
+ getState() {
433
+ sampleState.far = state.outputFar;
434
+ sampleState.fov = state.outputFov;
435
+ sampleState.near = state.outputNear;
436
+ state.outputLookAt.toArray(sampleState.lookAt);
437
+ state.outputPosition.toArray(sampleState.position);
438
+ state.outputQuaternion.toArray(sampleState.quaternion);
439
+ return sampleState;
440
+ },
441
+ reset({ snap = true } = {}) {
442
+ state.initialized = false;
443
+ state.collisionInitialized = false;
444
+ state.targetVelocity.set(0, 0, 0);
445
+ state.impulses.length = 0;
446
+ for (const entry of instances) entry.instance.reset?.(state);
447
+ if (snap) rig.update(0, { apply: true });
448
+ },
449
+ setCollisionQuery(next) {
450
+ query = typeof next === 'function' ? next : null;
451
+ state.collisionQuery = query;
452
+ return rig;
453
+ },
454
+ setOperators(next) {
455
+ const nextDefinitions = normalizeCameraOperators(next);
456
+ const enabledDefinitions = nextDefinitions
457
+ .filter((definition) => definition.enabled)
458
+ .sort((a, b) => a.order - b.order);
459
+ for (const definition of enabledDefinitions) {
460
+ const factory = localFactories[definition.type] ?? operatorFactories.get(definition.type);
461
+ if (typeof factory !== 'function') {
462
+ throw new Error(`Unknown camera operator "${definition.type}". Register it before creating the rig.`);
463
+ }
464
+ }
465
+ const nextInstances = [];
466
+ try {
467
+ for (const definition of enabledDefinitions) {
468
+ const factory = localFactories[definition.type] ?? operatorFactories.get(definition.type);
469
+ const instance = factory(definition, rig);
470
+ if (!instance || typeof instance.update !== 'function') {
471
+ throw new Error(`Camera operator "${definition.type}" must return an object with update(context).`);
472
+ }
473
+ nextInstances.push({ definition, instance });
474
+ }
475
+ } catch (error) {
476
+ for (const entry of nextInstances) entry.instance.dispose?.();
477
+ throw error;
478
+ }
479
+ for (const entry of instances) entry.instance.dispose?.();
480
+ definitions = nextDefinitions;
481
+ instances = nextInstances;
482
+ state.initialized = false;
483
+ return rig;
484
+ },
485
+ setSettings(next) {
486
+ rigSettings = createCameraSettings(next);
487
+ state.settings = rigSettings;
488
+ return rig;
489
+ },
490
+ setTarget(next) {
491
+ targetSource = next;
492
+ state.initialized = false;
493
+ return rig;
494
+ },
495
+ syncFromCamera() {
496
+ state.currentPosition.copy(camera.position);
497
+ state.outputPosition.copy(camera.position);
498
+ state.outputQuaternion.copy(camera.quaternion);
499
+ state.currentFov = finite(camera.fov, rigSettings.lens.fov);
500
+ state.outputFov = state.currentFov;
501
+ state.initialized = false;
502
+ return rig;
503
+ },
504
+ update(delta = 0, { apply = true } = {}) {
505
+ if (disposed) return rig.getState();
506
+ const dt = clamp(finite(delta, 0), 0, 0.25);
507
+ state.delta = dt;
508
+ state.collisionQuery = query;
509
+ state.settings = rigSettings;
510
+ state.target = resolveTarget(targetSource, state.targetPosition, state.targetQuaternion);
511
+ state.teleported = state.initialized && state.targetPosition.distanceTo(previousTarget) > rigSettings.damping.teleportDistance;
512
+ if (!state.initialized || dt <= 0 || state.teleported) {
513
+ measuredVelocity.set(0, 0, 0);
514
+ state.targetVelocity.set(0, 0, 0);
515
+ } else {
516
+ measuredVelocity.subVectors(state.targetPosition, previousTarget).multiplyScalar(1 / dt);
517
+ state.targetVelocity.lerp(measuredVelocity, dampAlpha(rigSettings.lookAhead.smoothing, dt));
518
+ }
519
+ previousTarget.copy(state.targetPosition);
520
+ state.desiredPosition.copy(state.currentPosition);
521
+ state.desiredLookAt.copy(state.targetPosition);
522
+ state.outputPosition.copy(state.currentPosition);
523
+ state.outputLookAt.copy(state.currentLookAt);
524
+ state.outputRotation.set(0, 0, 0);
525
+ state.collisionHit = false;
526
+ for (const entry of instances) entry.instance.update(state, entry.definition);
527
+ state.initialized = true;
528
+ state.counters.updates += 1;
529
+
530
+ lookMatrix.lookAt(state.outputPosition, state.outputLookAt, state.up);
531
+ state.outputQuaternion.setFromRotationMatrix(lookMatrix);
532
+ impulseEuler.set(state.outputRotation.x, state.outputRotation.y, state.outputRotation.z, 'YXZ');
533
+ impulseQuaternion.setFromEuler(impulseEuler);
534
+ state.outputQuaternion.multiply(impulseQuaternion);
535
+ if (apply) {
536
+ camera.position.copy(state.outputPosition);
537
+ camera.quaternion.copy(state.outputQuaternion);
538
+ if (camera.isPerspectiveCamera) {
539
+ camera.fov = state.outputFov;
540
+ camera.near = state.outputNear;
541
+ camera.far = state.outputFar;
542
+ camera.updateProjectionMatrix();
543
+ }
544
+ camera.updateMatrixWorld();
545
+ }
546
+ return rig.getState();
547
+ },
548
+ };
549
+
550
+ Object.defineProperties(rig, {
551
+ disposed: { get: () => disposed },
552
+ operators: { get: () => definitions.map((operator) => ({ ...operator, settings: { ...operator.settings } })) },
553
+ settings: { get: () => createCameraSettings(rigSettings) },
554
+ stats: {
555
+ get: () => ({
556
+ activeImpulses: state.impulses.length,
557
+ activeOperators: instances.length,
558
+ collisionHit: state.collisionHit,
559
+ collisionHits: state.counters.collisionHits,
560
+ collisionQueries: state.counters.collisionQueries,
561
+ disposed,
562
+ lastDelta: state.delta,
563
+ updates: state.counters.updates,
564
+ }),
565
+ },
566
+ });
567
+
568
+ rig.setOperators(definitions);
569
+ return rig;
570
+ }