@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.
- package/AGENTS.md +127 -4
- package/ATTRIBUTION.md +41 -3
- package/README.md +234 -34
- package/docs/characters.md +144 -0
- package/docs/debug-panel.md +126 -0
- package/docs/docs.css +589 -0
- package/docs/environment.md +186 -0
- package/docs/getting-started.md +180 -0
- package/docs/index.html +14 -0
- package/docs/lab-architecture.md +100 -0
- package/docs/lighting.md +825 -0
- package/docs/main.jsx +739 -0
- package/docs/mcp.md +97 -0
- package/docs/post-processing.md +98 -0
- package/docs/settings-reference.md +1878 -0
- package/docs/shader-constants.md +77 -0
- package/docs/sky.md +182 -0
- package/docs/style-labs.md +309 -0
- package/docs/texture-lab.md +135 -0
- package/docs/toon-shading.md +183 -0
- package/docs/tsl-conventions.md +167 -0
- package/docs/vegetation-sky.md +275 -0
- package/docs/water.md +430 -0
- package/docs/weather.md +200 -0
- package/docs/world-scale.md +111 -0
- package/mcp/server.mjs +610 -0
- package/mcp/style-lab-tools.mjs +371 -0
- package/mcp/vite-plugin.mjs +174 -0
- package/mcp/workspace.mjs +397 -0
- package/package.json +64 -5
- package/src/ambientfx/INTEGRATION.md +164 -0
- package/src/ambientfx/ambientFxPresets.js +83 -0
- package/src/ambientfx/ambientFxSettings.js +368 -0
- package/src/ambientfx/emitters.js +169 -0
- package/src/ambientfx/index.js +5 -0
- package/src/ambientfx/particleBackbone.js +493 -0
- package/src/ambientfx/stylizedAmbientFx.js +450 -0
- package/src/assetlib/ambientcg.js +117 -0
- package/src/assetlib/assetRef.js +91 -0
- package/src/assetlib/importedEntry.js +40 -0
- package/src/assetlib/index.js +23 -0
- package/src/assetlib/kaykit.js +192 -0
- package/src/assetlib/kaykitStaticIndex.js +700 -0
- package/src/assetlib/loadImported.js +143 -0
- package/src/assetlib/opensource3d.js +113 -0
- package/src/assetlib/polyhaven.js +182 -0
- package/src/assetlib/polypizza.js +115 -0
- package/src/assetlib/smithsonian.js +214 -0
- package/src/assetlib/sources.js +279 -0
- package/src/assetlib/zip.js +58 -0
- package/src/biome/biomeGenerator.js +385 -0
- package/src/biome/biomeRuntime.js +299 -0
- package/src/biome/index.js +2 -0
- package/src/buildinggen/buildingAsset.js +44 -0
- package/src/buildinggen/buildingGrammar.js +311 -0
- package/src/buildinggen/buildingMesh.js +451 -0
- package/src/buildinggen/buildingPresets.js +46 -0
- package/src/buildinggen/buildingRecipe.js +100 -0
- package/src/buildinggen/buildingSettings.js +238 -0
- package/src/buildinggen/index.js +6 -0
- package/src/camera/cameraDirector.js +157 -0
- package/src/camera/cameraGenerator.js +367 -0
- package/src/camera/cameraRig.js +570 -0
- package/src/camera/cameraSettings.js +236 -0
- package/src/camera/index.js +7 -0
- package/src/catalog/builtinEntries.js +245 -0
- package/src/catalog/catalog.js +210 -0
- package/src/catalog/index.js +3 -0
- package/src/catalog/manifest.js +84 -0
- package/src/core/generation.js +529 -0
- package/src/environment/environmentRigs.js +21 -1
- package/src/environment/environmentSettings.js +4 -0
- package/src/environment/environmentSunShadowPass.js +8 -0
- package/src/fauna/INTEGRATION.md +174 -0
- package/src/fauna/boids.js +861 -0
- package/src/fauna/faunaBodies.js +492 -0
- package/src/fauna/faunaPresets.js +52 -0
- package/src/fauna/faunaSettings.js +525 -0
- package/src/fauna/index.js +5 -0
- package/src/fauna/stylizedFauna.js +395 -0
- package/src/game-feel/gameFeelGenerator.js +402 -0
- package/src/game-feel/gameFeelRuntime.js +549 -0
- package/src/game-feel/index.js +2 -0
- package/src/index.js +17 -6
- package/src/lighting/colorIntensity.js +177 -0
- package/src/lighting/index.js +161 -0
- package/src/lighting/lightDescriptors.js +249 -0
- package/src/lighting/lightingCapabilities.js +79 -0
- package/src/lighting/lightingDocuments.js +247 -0
- package/src/lighting/lightingFixtures.js +446 -0
- package/src/lighting/lightingGenerator.js +449 -0
- package/src/lighting/lightingPresets.js +319 -0
- package/src/lighting/lightingRuntime.js +723 -0
- package/src/lighting/lightingStyle.js +386 -0
- package/src/lighting/lightingSystem.js +774 -0
- package/src/lighting/unrealExport.js +186 -0
- package/src/lighting/utils.js +87 -0
- package/src/motion/index.js +5 -0
- package/src/motion/motionClip.js +441 -0
- package/src/motion/motionController.js +628 -0
- package/src/motion/motionDocuments.js +225 -0
- package/src/motion/motionGraph.js +307 -0
- package/src/motion/motionSettings.js +222 -0
- package/src/pathgen/index.js +7 -0
- package/src/pathgen/pathBridge.js +232 -0
- package/src/pathgen/pathPresets.js +35 -0
- package/src/pathgen/pathRibbon.js +410 -0
- package/src/pathgen/pathRouter.js +380 -0
- package/src/pathgen/pathSettings.js +335 -0
- package/src/pathgen/pathTextures.js +123 -0
- package/src/pathgen/stylizedPaths.js +453 -0
- package/src/post/index.js +1 -0
- package/src/post/postGenerator.js +177 -0
- package/src/post/postProcessing.js +41 -0
- package/src/propgen/generatorsWave1.js +379 -0
- package/src/propgen/generatorsWave2.js +462 -0
- package/src/propgen/index.js +5 -0
- package/src/propgen/propAsset.js +323 -0
- package/src/propgen/propParts.js +170 -0
- package/src/propgen/propPlacement.js +459 -0
- package/src/propgen/propPresets.js +82 -0
- package/src/propgen/propSettings.js +395 -0
- package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
- package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
- package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
- package/src/shaders-tsl/chunks/water-waves.js +90 -11
- package/src/shaders-tsl/environment.js +15 -1
- package/src/shaders-tsl/flower.js +279 -30
- package/src/shaders-tsl/grass.js +60 -33
- package/src/shaders-tsl/sky.js +125 -31
- package/src/shaders-tsl/tree-leaf.js +61 -25
- package/src/shaders-tsl/water-breaker.js +7 -4
- package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
- package/src/shaders-tsl/water.js +439 -49
- package/src/shaders-tsl/woody-surface.js +154 -0
- package/src/sky/sceneOverrideLayers.js +10 -0
- package/src/sky/skyQuality.js +26 -0
- package/src/sky/stylizedSky.js +753 -45
- package/src/soundscape/index.js +4 -0
- package/src/soundscape/soundscapeGenerator.js +179 -0
- package/src/soundscape/soundscapeRuntime.js +806 -0
- package/src/soundscape/soundscapeSettings.js +292 -0
- package/src/styles/index.js +13 -0
- package/src/styles/styleBundle.js +325 -0
- package/src/stylizedTerrain.js +32 -2
- package/src/stylizedWorld.js +423 -20
- package/src/texgen/evaluateTexture.js +675 -0
- package/src/texgen/index.js +60 -0
- package/src/texgen/noise2.js +210 -0
- package/src/texgen/textureAi.js +436 -0
- package/src/texgen/textureGenerators.js +516 -0
- package/src/texgen/texturePresets.js +490 -0
- package/src/texgen/textureSettings.js +342 -0
- package/src/texgen/textureThree.js +59 -0
- package/src/vegetation/flowerSpecies.js +15 -3
- package/src/vegetation/grassPalettes.js +153 -0
- package/src/vegetation/index.js +6 -0
- package/src/vegetation/stylizedBush.js +2 -0
- package/src/vegetation/stylizedFlower.js +82 -0
- package/src/vegetation/stylizedFlowers.js +48 -7
- package/src/vegetation/stylizedForest.js +29 -1
- package/src/vegetation/stylizedGrass.js +291 -56
- package/src/vegetation/stylizedTree.js +38 -2
- package/src/vegetation/stylizedTreeFoliage.js +2 -1
- package/src/vegetation/vegetationShaders.js +1110 -0
- package/src/vfxgen/INTEGRATION.md +145 -0
- package/src/vfxgen/core/burstBackbone.js +380 -0
- package/src/vfxgen/core/projectileCore.js +92 -0
- package/src/vfxgen/core/spriteShapes.js +98 -0
- package/src/vfxgen/core/trailRibbon.js +272 -0
- package/src/vfxgen/core/vfxRandom.js +29 -0
- package/src/vfxgen/effects/emitHelpers.js +37 -0
- package/src/vfxgen/effects/magicEffects.js +162 -0
- package/src/vfxgen/effects/movementEffects.js +87 -0
- package/src/vfxgen/effects/weaponEffects.js +118 -0
- package/src/vfxgen/index.js +18 -0
- package/src/vfxgen/moves/moveController.js +146 -0
- package/src/vfxgen/moves/moveLibrary.js +335 -0
- package/src/vfxgen/vfxPresets.js +98 -0
- package/src/vfxgen/vfxSettings.js +384 -0
- package/src/vfxgen/vfxSystem.js +449 -0
- package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
- package/src/villagegen/index.js +4 -0
- package/src/villagegen/stylizedVillage.js +490 -0
- package/src/villagegen/villageArchetypes.js +160 -0
- package/src/villagegen/villageNames.js +40 -0
- package/src/villagegen/villageSites.js +105 -0
- package/src/water/sceneOverrideLayers.js +23 -0
- package/src/water/water.js +5 -0
- package/src/water/waterBreakerSystem.js +15 -1
- package/src/water/waterCurrentField.js +447 -0
- package/src/water/waterMaterial.js +12 -0
- package/src/water/waterNearshorePhase.js +320 -0
- package/src/water/waterScenePasses.js +83 -30
- package/src/water/waterSettings.js +325 -13
- package/src/water/waterShoreMaterial.js +322 -0
- package/src/water/waterShoreStateField.js +605 -0
- package/src/water/waterSurface.js +797 -28
- package/src/weather/index.js +6 -0
- package/src/weather/weatherPrecipitation.js +221 -0
- package/src/weather/weatherPresets.js +258 -0
- package/src/weather/weatherSettings.js +269 -0
- package/src/weather/weatherSystem.js +871 -0
- package/src/worldMinimap.js +62 -0
- package/src/worldPresets.js +4 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Gameplay VFX (vfxgen) — integration notes
|
|
2
|
+
|
|
3
|
+
The `src/vfxgen/` cluster is self-contained and ALREADY wired into the shared
|
|
4
|
+
files (unlike the ambientfx handoff doc, nothing here is pending):
|
|
5
|
+
|
|
6
|
+
- `src/index.js` — root re-export (`export * from './vfxgen/index.js'`)
|
|
7
|
+
- `package.json` — `"./vfxgen"` subpath export + `verify:vfxgen` script
|
|
8
|
+
(also folded into `verify:all`)
|
|
9
|
+
- `vite.config.js` — `'vfxgen'` package alias + `vfxArena` build input
|
|
10
|
+
- `scripts/generate-settings-reference.mjs` — Gameplay VFX module entry
|
|
11
|
+
- `labs/shared/sceneHub.js` — `vfxLab` + `vfxArena` hub entries
|
|
12
|
+
- Demo/reference app: `examples/vfx-arena/` (headless gates on
|
|
13
|
+
`document.body.dataset`: `vfxArenaReady`, `vfxLiveGlow`, `vfxLivePuff`,
|
|
14
|
+
`vfxDrawCalls`, `vfxSpawns`)
|
|
15
|
+
- **Designer: VFX Lab at `/vfx-lab/`** (`labs/vfx-lab/`) — the interactive
|
|
16
|
+
authoring surface: category rail (weapon / magic / movement / shared),
|
|
17
|
+
schema-driven panels generated from `VFX_SETTING_FIELD_SCHEMA`, preset
|
|
18
|
+
picker, gameplay trigger bar (hotkeys 1–5, click-to-aim fireballs, `L`
|
|
19
|
+
loop, `R` reseed), and Export → recipe JSON / paste-ready
|
|
20
|
+
`createVfxSystem` code. Share links carry the recipe in `?vfxRecipe=`.
|
|
21
|
+
Headless gates: `vfxLabReady`, `uiReady`, plus the arena stats above.
|
|
22
|
+
|
|
23
|
+
## Layout (categorized)
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
src/vfxgen/
|
|
27
|
+
vfxSettings.js DEFAULT_VFX_SETTINGS + groups + field schema
|
|
28
|
+
vfxPresets.js 'default' + 'call_me_sensei' + registerVfxPreset()
|
|
29
|
+
vfxSystem.js createVfxSystem — event-driven runtime, pools, clock
|
|
30
|
+
core/ rendering machinery (no gameplay knowledge)
|
|
31
|
+
burstBackbone.js ring-buffer one-shot GPU particles, 2 draw calls
|
|
32
|
+
(kinds: spark, ember, flash, ground ring, puff,
|
|
33
|
+
camera-facing shockwave)
|
|
34
|
+
trailRibbon.js swept-arc ribbon (slash trails, limb smears) —
|
|
35
|
+
reference-style SOLID two-band surface (body +
|
|
36
|
+
white leading edge), tail ERODES (hard dissolving
|
|
37
|
+
cutoff, inner edge first) instead of alpha-fading,
|
|
38
|
+
Catmull-Rom smoothed centerline (SUBDIV spline
|
|
39
|
+
points per raw frame sample), charged-edge flash
|
|
40
|
+
on the newest segments
|
|
41
|
+
projectileCore.js pooled flame-core billboard
|
|
42
|
+
spriteShapes.js TSL SDF sprite masks (zero textures)
|
|
43
|
+
vfxRandom.js seeded PRNG trio (internal, not re-exported)
|
|
44
|
+
effects/ PURE builders: (settings, rng, options) → records
|
|
45
|
+
weaponEffects.js emitImpact, emitSlashSparkle
|
|
46
|
+
magicEffects.js emitFireballEmbers, emitFireballExplosion
|
|
47
|
+
movementEffects.js emitFootstep, emitLanding
|
|
48
|
+
weapons/
|
|
49
|
+
stylizedWeapons.js procedural weapons (sword, greatsword, spear,
|
|
50
|
+
dagger, hammer): mesh + trail anchors + weight profile
|
|
51
|
+
moves/
|
|
52
|
+
moveLibrary.js PURE authored move data — phases + pose keys + VFX
|
|
53
|
+
event tracks (slash, overhead, thrust, spin, plunge)
|
|
54
|
+
moveController.js plays a move on a weapon, fires its events into vfx
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Weapons + moves (the batteries-included motion layer)
|
|
58
|
+
|
|
59
|
+
The VFX rides real motion: a move is authored phase data (`windup → strike
|
|
60
|
+
→ recover`; the plunge is the full Dragoon decomposition `crouch → leap →
|
|
61
|
+
apex → dive → landfall → recover`), each phase carrying its VFX beats
|
|
62
|
+
(trail on/off, impact, landing, dust). Weapon `weight` scales timing and
|
|
63
|
+
hit power — the same `overhead` reads as a dagger flick or a hammer commit.
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
import {
|
|
67
|
+
createMoveController, createStylizedWeapon, createVfxSystem,
|
|
68
|
+
} from '@call-me-sensei/toonlab/vfxgen';
|
|
69
|
+
|
|
70
|
+
const weapon = createStylizedWeapon({ type: 'greatsword' });
|
|
71
|
+
characterHand.add(weapon.root); // or an actor anchor group
|
|
72
|
+
const attack = createMoveController({ weapon, vfx, groundY: 0 });
|
|
73
|
+
attack.play('overhead'); // on the attack input
|
|
74
|
+
attack.update(delta); // per frame, before vfx.update
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Own animations stay first-class: skip the controller and drive
|
|
78
|
+
`vfx.spawn('slash', { follow: bone, base, tip })` from your clips — the
|
|
79
|
+
move library is default motion, not a requirement. External weapon meshes
|
|
80
|
+
slot in by providing the same `{ root, anchors, profile }` shape.
|
|
81
|
+
|
|
82
|
+
Categories: **weapon** (`slash`, `impact`) · **magic** (`fireball`) ·
|
|
83
|
+
**movement** (`footstep`, `landing`). `VFX_CATEGORIES` in vfxSettings.js is
|
|
84
|
+
the canonical map; new effects should join a category (or add one) and keep
|
|
85
|
+
their emission logic as a pure builder in `effects/`.
|
|
86
|
+
|
|
87
|
+
## Host usage (standalone — works in any Three.js scene)
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
import { createVfxSystem } from '@call-me-sensei/toonlab/vfxgen';
|
|
91
|
+
|
|
92
|
+
const vfx = createVfxSystem({
|
|
93
|
+
seed: 42,
|
|
94
|
+
preset: 'call_me_sensei',
|
|
95
|
+
heightAt: world?.collision?.groundHeight ?? terrain?.heightAt, // fireball ground hits
|
|
96
|
+
});
|
|
97
|
+
scene.add(vfx.root);
|
|
98
|
+
|
|
99
|
+
// per frame, before render:
|
|
100
|
+
vfx.update(delta, camera);
|
|
101
|
+
|
|
102
|
+
// gameplay events:
|
|
103
|
+
const trail = vfx.spawn('slash', { follow: weaponBone, base: [0, 0.5, 0], tip: [0, 1.3, 0] });
|
|
104
|
+
trail.stop(); // when the swing anim ends
|
|
105
|
+
vfx.spawn('impact', { at, normal, power: 1 });
|
|
106
|
+
const bolt = vfx.spawn('fireball', { from, velocity, onHit });
|
|
107
|
+
vfx.spawn('footstep', { at, dir: velocityXZ });
|
|
108
|
+
vfx.spawn('landing', { at, power: fallSpeed / 6 });
|
|
109
|
+
|
|
110
|
+
// world look integration:
|
|
111
|
+
vfx.setDistanceFog({ color, density, falloff, floorY }); // same params as forest/water
|
|
112
|
+
vfx.setTimeScale(0); // hit-stop
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Budgets: all one-shot bursts render in **two draw calls** (glow + puff ring
|
|
116
|
+
buffers, `shared.maxParticles` instances total); each live slash ribbon and
|
|
117
|
+
fireball core adds one small draw, bounded by `shared.maxTrails` /
|
|
118
|
+
`shared.maxProjectiles`. Everything is `userData.waterExclude` — transient
|
|
119
|
+
flashes are not worth re-rendering the water passes.
|
|
120
|
+
|
|
121
|
+
## Deferred follow-ups (deliberate)
|
|
122
|
+
|
|
123
|
+
- **`createStylizedWorld({ vfx })` wiring** — mirror the ambientfx option:
|
|
124
|
+
construct the system with the world's `heightAt` + follow target, call
|
|
125
|
+
`vfx.setDistanceFog` from the world's height-fog params, tick it inside
|
|
126
|
+
`world.update`. Waiting until a real consumer (mini-game / runtime spine)
|
|
127
|
+
exists so the option surface is shaped by actual use.
|
|
128
|
+
- **Limb smears** — `createTrailRibbon` already supports any Object3D +
|
|
129
|
+
anchor pair; a character-side helper that arms ribbons from bone names
|
|
130
|
+
belongs in `src/character/` when procedural animation lands.
|
|
131
|
+
- **Catalog entries** — effects are runtime events, not spawnable assets;
|
|
132
|
+
if the catalog ever grows an "effects" shelf, register preset spawns, not
|
|
133
|
+
meshes.
|
|
134
|
+
|
|
135
|
+
## Verification
|
|
136
|
+
|
|
137
|
+
- `npm run verify:vfxgen` — node-only: determinism (same seed + same spawn
|
|
138
|
+
script → bit-identical buffers), ring-buffer budgets, lifecycle (expiry,
|
|
139
|
+
ribbon pooling, ground detonation + onHit), look overrides, hit-stop, and
|
|
140
|
+
the pure builders' geometry contracts.
|
|
141
|
+
- Visual: drive `examples/vfx-arena/?hud=0` headlessly (Playwright,
|
|
142
|
+
`--enable-unsafe-webgpu --enable-gpu`), wait for
|
|
143
|
+
`dataset.vfxArenaReady === 'true'`, screenshot several beats, and LOOK at
|
|
144
|
+
them (slash = crescent arc, impact = star + spark streaks, fireball =
|
|
145
|
+
two-band flame + ember trail, detonation = ring + smoke column).
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// The ONE shared GPU system behind every one-shot burst in the VFX cluster
|
|
2
|
+
// (sparks, embers, hit flashes, ground rings, dust puffs). Same architecture
|
|
3
|
+
// as the ambientfx particle backbone: an InstancedBufferGeometry over a unit
|
|
4
|
+
// quad and ALL motion computed in the TSL vertex shader as a pure function of
|
|
5
|
+
// (uTime − birth) — after the spawn-time attribute write, a burst costs zero
|
|
6
|
+
// CPU per frame, on WebGPU and the WebGL2 fallback alike.
|
|
7
|
+
//
|
|
8
|
+
// Where ambientfx re-emits a follow window, this is a RING BUFFER: spawns
|
|
9
|
+
// write records at a cursor and expired instances collapse to degenerate
|
|
10
|
+
// quads in the vertex shader (zero fragments) until overwritten. Draw calls:
|
|
11
|
+
// every effect shares TWO meshes, grouped by blend state —
|
|
12
|
+
// glow — sparks + embers + flashes + rings (additive, no depth write)
|
|
13
|
+
// puff — dust/smoke puffs (alpha blend, no depth write)
|
|
14
|
+
// Within a group the per-instance `kind` attribute selects the motion
|
|
15
|
+
// program (ballistic / buoyant-wander / flash-pop / ground-ring / puff), so
|
|
16
|
+
// adding an effect never adds a draw.
|
|
17
|
+
|
|
18
|
+
import * as THREE from 'three';
|
|
19
|
+
import { NodeMaterial } from 'three/webgpu';
|
|
20
|
+
import {
|
|
21
|
+
attribute, cameraPosition, cameraProjectionMatrix, cameraViewMatrix, clamp, cos, Discard,
|
|
22
|
+
distance, exp, float, Fn, max, mix, normalize, positionLocal, pow, sin, smoothstep, step,
|
|
23
|
+
uniform, uv, varying, vec2, vec3, vec4,
|
|
24
|
+
} from 'three/tsl';
|
|
25
|
+
|
|
26
|
+
import { emberDot, puffBlob, ringBand, softDot, starburst } from './spriteShapes.js';
|
|
27
|
+
|
|
28
|
+
/** Backbone kind ids — the per-instance `iData.w` attribute. `ring` lies
|
|
29
|
+
* flat on the ground (scorch); `shockwave` is the same expanding band but
|
|
30
|
+
* camera-facing (the action-RPG hit circle). */
|
|
31
|
+
export const BURST_KIND = Object.freeze({
|
|
32
|
+
spark: 0, ember: 1, flash: 2, ring: 3, puff: 4, shockwave: 5,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Which blend-group mesh each kind renders in. */
|
|
36
|
+
export const GROUP_FOR_BURST_KIND = Object.freeze({
|
|
37
|
+
spark: 'glow', ember: 'glow', flash: 'glow', ring: 'glow', puff: 'puff', shockwave: 'glow',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// --- geometry ----------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const FLOATS_PER_INSTANCE = { iSpawn: 3, iVel: 3, iData: 4, iColor: 3, iAux: 4 };
|
|
43
|
+
|
|
44
|
+
function createGroupGeometry(capacity) {
|
|
45
|
+
const quad = new THREE.PlaneGeometry(1, 1);
|
|
46
|
+
const geometry = new THREE.InstancedBufferGeometry();
|
|
47
|
+
geometry.index = quad.index;
|
|
48
|
+
geometry.setAttribute('position', quad.attributes.position);
|
|
49
|
+
geometry.setAttribute('uv', quad.attributes.uv);
|
|
50
|
+
for (const [name, itemSize] of Object.entries(FLOATS_PER_INSTANCE)) {
|
|
51
|
+
geometry.setAttribute(name, new THREE.InstancedBufferAttribute(
|
|
52
|
+
new Float32Array(Math.max(capacity, 1) * itemSize), itemSize));
|
|
53
|
+
}
|
|
54
|
+
geometry.instanceCount = 0;
|
|
55
|
+
// Bursts happen anywhere in the world; never let culling clip a hit flash.
|
|
56
|
+
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e5);
|
|
57
|
+
return geometry;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Creates the two blend-group meshes plus their shared uniforms.
|
|
62
|
+
*
|
|
63
|
+
* Each group exposes `emit(records)` where a record is
|
|
64
|
+
* `{ x, y, z, vx, vy, vz, seed, birth, lifetime, kind, r, g, b,
|
|
65
|
+
* size0, size1, gravity, extra }` — the effects/ builders produce exactly
|
|
66
|
+
* this shape. `extra` is kind-specific: spark → streak stretch, ember →
|
|
67
|
+
* wander radius, flash → star spikes, ring → band thickness, puff → spin.
|
|
68
|
+
*/
|
|
69
|
+
export function createBurstBackbone(settings) {
|
|
70
|
+
const capacity = Math.max(settings?.shared?.maxParticles ?? 4096, 64);
|
|
71
|
+
// Glow kinds outnumber puffs in practice (sparks + embers per burst vs a
|
|
72
|
+
// handful of dust quads); split the budget accordingly.
|
|
73
|
+
const glowCapacity = Math.ceil(capacity * 0.75);
|
|
74
|
+
const puffCapacity = Math.max(capacity - glowCapacity, 32);
|
|
75
|
+
|
|
76
|
+
const u = {
|
|
77
|
+
uCamRight: uniform(new THREE.Vector3(1, 0, 0)),
|
|
78
|
+
uCamUp: uniform(new THREE.Vector3(0, 1, 0)),
|
|
79
|
+
uFogColor: uniform(new THREE.Color(0.66, 0.8, 0.94)),
|
|
80
|
+
uFogDensity: uniform(0),
|
|
81
|
+
uFogFalloff: uniform(400),
|
|
82
|
+
uFogFloorY: uniform(0),
|
|
83
|
+
uTime: uniform(0),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Mirror of the environment shader's world-height fog (same formula as the
|
|
87
|
+
// ambientfx backbone) so bursts haze out with the terrain. Density 0 = off.
|
|
88
|
+
const heightFogFactor = (worldPos) => {
|
|
89
|
+
const heightFalloff = exp(
|
|
90
|
+
max(worldPos.y.sub(u.uFogFloorY), 0.0).div(max(u.uFogFalloff, 0.001)).negate());
|
|
91
|
+
const depthTerm = exp(distance(worldPos, cameraPosition).mul(u.uFogDensity).negate()).oneMinus();
|
|
92
|
+
return clamp(depthTerm.mul(heightFalloff), 0.0, 1.0);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Shared per-instance lifecycle: progress 0..1 over the lifetime, and an
|
|
96
|
+
// alive gate that multiplies into quad SIZE — a degenerate quad rasterizes
|
|
97
|
+
// nothing, so expired ring-buffer slots cost zero fragments.
|
|
98
|
+
const lifecycle = () => {
|
|
99
|
+
const iData = attribute('iData', 'vec4');
|
|
100
|
+
const age = u.uTime.sub(iData.y).toVar();
|
|
101
|
+
const lifetime = max(iData.z, 1e-3);
|
|
102
|
+
const progress = clamp(age.div(lifetime), 0.0, 1.0).toVar();
|
|
103
|
+
// −5 ms tolerance: float32 rounding of birth vs the clock must never
|
|
104
|
+
// hide a burst on its own spawn frame (hit flashes land THIS frame).
|
|
105
|
+
const alive = step(-0.005, age)
|
|
106
|
+
.mul(step(age.div(lifetime), 0.9995).toVar())
|
|
107
|
+
.mul(step(1e-3, iData.z));
|
|
108
|
+
return { age, alive, progress, seed: iData.x, kind: iData.w };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const buildMaterial = (name, build) => {
|
|
112
|
+
const material = new NodeMaterial();
|
|
113
|
+
material.name = name;
|
|
114
|
+
build(material);
|
|
115
|
+
material.uniforms = u; // GLSL-style named access, matching the grass idiom
|
|
116
|
+
return material;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ---- glow group: spark (ballistic) + ember (buoyant wander) + flash
|
|
120
|
+
// (scale pop) + ring (ground expand) — additive, emissive by design -------
|
|
121
|
+
const glowMaterial = buildMaterial('VfxBurstGlow', (material) => {
|
|
122
|
+
material.transparent = true;
|
|
123
|
+
material.blending = THREE.AdditiveBlending;
|
|
124
|
+
material.depthWrite = false;
|
|
125
|
+
material.fog = false; // both fog layers only DIM the glow (see ambientfx)
|
|
126
|
+
|
|
127
|
+
const vUv = uv();
|
|
128
|
+
const vColor = varying(vec3(), 'vVfxColor');
|
|
129
|
+
const vSeed = varying(float(), 'vVfxSeed');
|
|
130
|
+
const vKind = varying(float(), 'vVfxKind');
|
|
131
|
+
const vProgress = varying(float(), 'vVfxProgress');
|
|
132
|
+
const vExtra = varying(float(), 'vVfxExtra');
|
|
133
|
+
const vWorldPos = varying(vec3(), 'vVfxWorldPos');
|
|
134
|
+
|
|
135
|
+
material.vertexNode = Fn(() => {
|
|
136
|
+
const iSpawn = attribute('iSpawn', 'vec3');
|
|
137
|
+
const iVel = attribute('iVel', 'vec3');
|
|
138
|
+
const iAux = attribute('iAux', 'vec4');
|
|
139
|
+
const { age, alive, progress, seed, kind } = lifecycle();
|
|
140
|
+
vSeed.assign(seed);
|
|
141
|
+
vKind.assign(kind);
|
|
142
|
+
vProgress.assign(progress);
|
|
143
|
+
vExtra.assign(iAux.w);
|
|
144
|
+
vColor.assign(attribute('iColor', 'vec3'));
|
|
145
|
+
|
|
146
|
+
const isEmber = step(0.5, kind).mul(step(kind, 1.5));
|
|
147
|
+
const isFlash = step(1.5, kind).mul(step(kind, 2.5));
|
|
148
|
+
const isRing = step(2.5, kind).mul(step(kind, 3.5)); // ground scorch only
|
|
149
|
+
const isShock = step(4.5, kind); // camera-facing hit circle
|
|
150
|
+
const isRingLike = max(isRing, isShock);
|
|
151
|
+
const isBallistic = step(kind, 1.5); // spark or ember
|
|
152
|
+
|
|
153
|
+
// Ballistic center: p = p0 + v·t − ½g·t²ŷ. Ember "gravity" is negative
|
|
154
|
+
// (buoyant), so the same program rises; its wander adds a curl-ish
|
|
155
|
+
// drift that grows over life (extra = wander radius).
|
|
156
|
+
const wander = vec3(
|
|
157
|
+
sin(age.mul(3.1).add(seed.mul(41.0))),
|
|
158
|
+
0.0,
|
|
159
|
+
cos(age.mul(2.6).add(seed.mul(23.0))),
|
|
160
|
+
).mul(iAux.w).mul(progress).mul(isEmber);
|
|
161
|
+
const center = iSpawn
|
|
162
|
+
.add(iVel.mul(age).mul(isBallistic))
|
|
163
|
+
.sub(vec3(0.0, 1.0, 0.0).mul(iAux.z).mul(age).mul(age).mul(0.5).mul(isBallistic))
|
|
164
|
+
.add(wander)
|
|
165
|
+
.toVar();
|
|
166
|
+
|
|
167
|
+
// Size envelope per kind. Flash pops out fast and dies before the end
|
|
168
|
+
// of its lifetime; the ring eases outward (shockwave decelerating).
|
|
169
|
+
const easeOut = progress.oneMinus().pow(3.0).oneMinus();
|
|
170
|
+
const sizeBallistic = mix(iAux.x, iAux.y, progress);
|
|
171
|
+
const sizeFlash = iAux.x
|
|
172
|
+
.mul(smoothstep(0.0, 0.22, progress).mul(0.65).add(0.35))
|
|
173
|
+
.mul(smoothstep(1.0, 0.55, progress));
|
|
174
|
+
const sizeRing = mix(iAux.x, iAux.y, easeOut);
|
|
175
|
+
const scale = mix(mix(sizeBallistic, sizeFlash, isFlash), sizeRing, isRingLike)
|
|
176
|
+
.mul(alive)
|
|
177
|
+
.toVar();
|
|
178
|
+
|
|
179
|
+
// Sparks stretch into streaks along their CURRENT velocity projected
|
|
180
|
+
// into the camera plane — rotation built from the normalized 2D vector,
|
|
181
|
+
// no atan needed. Everything else keeps the plain billboard basis.
|
|
182
|
+
const velNow = iVel.sub(vec3(0.0, iAux.z.mul(age), 0.0));
|
|
183
|
+
const projected = vec2(velNow.dot(u.uCamRight), velNow.dot(u.uCamUp)).add(vec2(1e-4, 0.0));
|
|
184
|
+
const isSpark = step(kind, 0.5);
|
|
185
|
+
const dir = mix(vec2(1.0, 0.0), normalize(projected), isSpark).toVar();
|
|
186
|
+
const stretch = mix(1.0, max(iAux.w, 1.0), isSpark);
|
|
187
|
+
const local = vec2(positionLocal.x.mul(stretch), positionLocal.y).toVar();
|
|
188
|
+
const rotated = vec2(
|
|
189
|
+
local.x.mul(dir.x).sub(local.y.mul(dir.y)),
|
|
190
|
+
local.x.mul(dir.y).add(local.y.mul(dir.x)),
|
|
191
|
+
);
|
|
192
|
+
const billboardOffset = u.uCamRight.mul(rotated.x).add(u.uCamUp.mul(rotated.y));
|
|
193
|
+
// Scorch rings lie flat on the ground (quad local XY → world XZ);
|
|
194
|
+
// shockwaves keep the camera-facing billboard basis.
|
|
195
|
+
const flatOffset = vec3(positionLocal.x, 0.0, positionLocal.y.negate());
|
|
196
|
+
const offset = mix(billboardOffset, flatOffset, isRing).mul(scale);
|
|
197
|
+
|
|
198
|
+
const worldPosition = vec4(center.add(offset), 1.0);
|
|
199
|
+
vWorldPos.assign(worldPosition.xyz);
|
|
200
|
+
return cameraProjectionMatrix.mul(cameraViewMatrix).mul(worldPosition);
|
|
201
|
+
})();
|
|
202
|
+
|
|
203
|
+
material.fragmentNode = Fn(() => {
|
|
204
|
+
const p = vUv.mul(2.0).sub(1.0).toVar();
|
|
205
|
+
const isEmber = step(0.5, vKind).mul(step(vKind, 1.5));
|
|
206
|
+
const isFlash = step(1.5, vKind).mul(step(vKind, 2.5));
|
|
207
|
+
const isRing = step(2.5, vKind);
|
|
208
|
+
const isSpark = step(vKind, 0.5);
|
|
209
|
+
|
|
210
|
+
// Ring band thins as it expands (vExtra = starting thickness).
|
|
211
|
+
const thickness = max(vExtra.mul(vProgress.mul(0.6).oneMinus()), 0.02);
|
|
212
|
+
const mask = softDot(p, 2.2).mul(isSpark)
|
|
213
|
+
.add(emberDot(p).mul(isEmber))
|
|
214
|
+
.add(starburst(p, vExtra, 3.0).mul(isFlash))
|
|
215
|
+
.add(ringBand(p, 0.72, thickness).mul(isRing))
|
|
216
|
+
.toVar();
|
|
217
|
+
|
|
218
|
+
// Brightness envelopes: sparks/embers cool, the flash dies fast, the
|
|
219
|
+
// ring fades as it runs out of energy.
|
|
220
|
+
const fade = vProgress.oneMinus();
|
|
221
|
+
const brightness = pow(fade, 1.5).mul(isSpark)
|
|
222
|
+
.add(fade.mul(isEmber))
|
|
223
|
+
.add(smoothstep(1.0, 0.45, vProgress).mul(isFlash))
|
|
224
|
+
.add(pow(fade, 1.2).mul(isRing));
|
|
225
|
+
|
|
226
|
+
const rgb = vColor.mul(mask).mul(brightness)
|
|
227
|
+
.mul(heightFogFactor(vWorldPos).oneMinus());
|
|
228
|
+
return vec4(rgb, mask.mul(brightness));
|
|
229
|
+
})();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ---- puff group: chunky toon dust/smoke — alpha blend, matter not light --
|
|
233
|
+
const puffMaterial = buildMaterial('VfxBurstPuff', (material) => {
|
|
234
|
+
material.transparent = true;
|
|
235
|
+
material.depthWrite = false;
|
|
236
|
+
material.fog = true;
|
|
237
|
+
|
|
238
|
+
const vUv = uv();
|
|
239
|
+
const vColor = varying(vec3(), 'vVfxColor');
|
|
240
|
+
const vSeed = varying(float(), 'vVfxSeed');
|
|
241
|
+
const vProgress = varying(float(), 'vVfxProgress');
|
|
242
|
+
const vSpin = varying(float(), 'vVfxSpin');
|
|
243
|
+
const vAge = varying(float(), 'vVfxAge');
|
|
244
|
+
const vWorldPos = varying(vec3(), 'vVfxWorldPos');
|
|
245
|
+
|
|
246
|
+
material.vertexNode = Fn(() => {
|
|
247
|
+
const iSpawn = attribute('iSpawn', 'vec3');
|
|
248
|
+
const iVel = attribute('iVel', 'vec3');
|
|
249
|
+
const iAux = attribute('iAux', 'vec4');
|
|
250
|
+
const { age, alive, progress, seed } = lifecycle();
|
|
251
|
+
vSeed.assign(seed);
|
|
252
|
+
vProgress.assign(progress);
|
|
253
|
+
vSpin.assign(iAux.w);
|
|
254
|
+
vAge.assign(age);
|
|
255
|
+
vColor.assign(attribute('iColor', 'vec3'));
|
|
256
|
+
|
|
257
|
+
// Decelerating launch (dust loses momentum fast) + gravity term the
|
|
258
|
+
// emitters use as gentle rise (negative) or settle (positive).
|
|
259
|
+
const decel = progress.mul(0.55).oneMinus();
|
|
260
|
+
const center = iSpawn
|
|
261
|
+
.add(iVel.mul(age).mul(decel))
|
|
262
|
+
.sub(vec3(0.0, 1.0, 0.0).mul(iAux.z).mul(age).mul(age).mul(0.5))
|
|
263
|
+
.toVar();
|
|
264
|
+
|
|
265
|
+
// Puffs GROW as they fade — the classic smoke read.
|
|
266
|
+
const easeOut = progress.oneMinus().pow(2.0).oneMinus();
|
|
267
|
+
const scale = mix(iAux.x, iAux.y, easeOut).mul(alive);
|
|
268
|
+
const position = center
|
|
269
|
+
.add(u.uCamRight.mul(positionLocal.x).mul(scale))
|
|
270
|
+
.add(u.uCamUp.mul(positionLocal.y).mul(scale));
|
|
271
|
+
const worldPosition = vec4(position, 1.0);
|
|
272
|
+
vWorldPos.assign(worldPosition.xyz);
|
|
273
|
+
return cameraProjectionMatrix.mul(cameraViewMatrix).mul(worldPosition);
|
|
274
|
+
})();
|
|
275
|
+
|
|
276
|
+
material.fragmentNode = Fn(() => {
|
|
277
|
+
// Slow tumble sells hand-drawn smoke; rotate the sample space, not the
|
|
278
|
+
// quad, so the billboard basis stays camera-true.
|
|
279
|
+
const spinAngle = vAge.mul(vSpin).add(vSeed.mul(6.2831));
|
|
280
|
+
const c = cos(spinAngle);
|
|
281
|
+
const s = sin(spinAngle);
|
|
282
|
+
const p0 = vUv.mul(2.0).sub(1.0).toVar();
|
|
283
|
+
const p = vec2(p0.x.mul(c).sub(p0.y.mul(s)), p0.x.mul(s).add(p0.y.mul(c))).toVar();
|
|
284
|
+
|
|
285
|
+
const blob = puffBlob(p, vSeed, 0.2);
|
|
286
|
+
Discard(blob.lessThan(0.0));
|
|
287
|
+
const coverage = smoothstep(0.0, 0.14, blob);
|
|
288
|
+
|
|
289
|
+
// Two-tone toon shading (lit top, shaded belly) + a stepped-ish fade so
|
|
290
|
+
// the dissolve reads as cel steps rather than an airbrush.
|
|
291
|
+
const shade = mix(0.8, 1.12, smoothstep(-0.7, 0.7, p0.y));
|
|
292
|
+
const fade = vProgress.oneMinus();
|
|
293
|
+
const alpha = coverage
|
|
294
|
+
.mul(smoothstep(0.0, 0.12, fade))
|
|
295
|
+
.mul(pow(fade, 0.75))
|
|
296
|
+
.mul(0.88);
|
|
297
|
+
|
|
298
|
+
const lit = vColor.mul(shade);
|
|
299
|
+
const fogged = mix(lit, u.uFogColor, heightFogFactor(vWorldPos));
|
|
300
|
+
return vec4(fogged, alpha);
|
|
301
|
+
})();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const buildGroup = (id, material, groupCapacity, renderOrder) => {
|
|
305
|
+
const mesh = new THREE.Mesh(createGroupGeometry(groupCapacity), material);
|
|
306
|
+
mesh.name = `VfxBurst${id[0].toUpperCase()}${id.slice(1)}`;
|
|
307
|
+
mesh.frustumCulled = false;
|
|
308
|
+
mesh.renderOrder = renderOrder;
|
|
309
|
+
// CPU mirror of each slot's lifecycle so stats/verify can count live
|
|
310
|
+
// instances without reading GPU state. Float64 on purpose: float32
|
|
311
|
+
// rounding can push a birth a hair past the clock and make a just-spawned
|
|
312
|
+
// instance read as unborn.
|
|
313
|
+
const birth = new Float64Array(groupCapacity);
|
|
314
|
+
const lifetime = new Float64Array(groupCapacity);
|
|
315
|
+
const group = {
|
|
316
|
+
capacity: groupCapacity,
|
|
317
|
+
cursor: 0,
|
|
318
|
+
highWater: 0,
|
|
319
|
+
id,
|
|
320
|
+
mesh,
|
|
321
|
+
/** Writes records at the ring cursor; oldest slots recycle first. */
|
|
322
|
+
emit(records) {
|
|
323
|
+
const spawn = mesh.geometry.attributes.iSpawn;
|
|
324
|
+
const vel = mesh.geometry.attributes.iVel;
|
|
325
|
+
const data = mesh.geometry.attributes.iData;
|
|
326
|
+
const color = mesh.geometry.attributes.iColor;
|
|
327
|
+
const aux = mesh.geometry.attributes.iAux;
|
|
328
|
+
for (const r of records) {
|
|
329
|
+
const i = group.cursor;
|
|
330
|
+
spawn.array[i * 3] = r.x; spawn.array[i * 3 + 1] = r.y; spawn.array[i * 3 + 2] = r.z;
|
|
331
|
+
vel.array[i * 3] = r.vx ?? 0; vel.array[i * 3 + 1] = r.vy ?? 0; vel.array[i * 3 + 2] = r.vz ?? 0;
|
|
332
|
+
data.array[i * 4] = r.seed; data.array[i * 4 + 1] = r.birth;
|
|
333
|
+
data.array[i * 4 + 2] = r.lifetime; data.array[i * 4 + 3] = r.kind;
|
|
334
|
+
color.array[i * 3] = r.r; color.array[i * 3 + 1] = r.g; color.array[i * 3 + 2] = r.b;
|
|
335
|
+
aux.array[i * 4] = r.size0; aux.array[i * 4 + 1] = r.size1;
|
|
336
|
+
aux.array[i * 4 + 2] = r.gravity ?? 0; aux.array[i * 4 + 3] = r.extra ?? 0;
|
|
337
|
+
birth[i] = r.birth;
|
|
338
|
+
lifetime[i] = r.lifetime;
|
|
339
|
+
group.cursor = (i + 1) % group.capacity;
|
|
340
|
+
group.highWater = Math.min(Math.max(group.highWater, i + 1), group.capacity);
|
|
341
|
+
}
|
|
342
|
+
if (records.length > 0) {
|
|
343
|
+
spawn.needsUpdate = vel.needsUpdate = data.needsUpdate = true;
|
|
344
|
+
color.needsUpdate = aux.needsUpdate = true;
|
|
345
|
+
mesh.geometry.instanceCount = group.highWater;
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
/** Instances still inside their lifetime at clock `now`. */
|
|
349
|
+
liveCount(now) {
|
|
350
|
+
let live = 0;
|
|
351
|
+
for (let i = 0; i < group.highWater; i += 1) {
|
|
352
|
+
if (lifetime[i] > 0 && now - birth[i] >= 0 && now - birth[i] < lifetime[i]) live += 1;
|
|
353
|
+
}
|
|
354
|
+
return live;
|
|
355
|
+
},
|
|
356
|
+
clear() {
|
|
357
|
+
lifetime.fill(0);
|
|
358
|
+
mesh.geometry.attributes.iData.array.fill(0);
|
|
359
|
+
mesh.geometry.attributes.iData.needsUpdate = true;
|
|
360
|
+
group.cursor = 0;
|
|
361
|
+
group.highWater = 0;
|
|
362
|
+
mesh.geometry.instanceCount = 0;
|
|
363
|
+
},
|
|
364
|
+
dispose() {
|
|
365
|
+
mesh.geometry.dispose();
|
|
366
|
+
material.dispose();
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
return group;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
groups: {
|
|
374
|
+
// Dust under the glows so sparks and flashes shine THROUGH the smoke.
|
|
375
|
+
puff: buildGroup('puff', puffMaterial, puffCapacity, 4),
|
|
376
|
+
glow: buildGroup('glow', glowMaterial, glowCapacity, 5),
|
|
377
|
+
},
|
|
378
|
+
uniforms: u,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The fireball's flame-core billboard: one pooled quad per projectile in
|
|
2
|
+
// flight, shaded by an analytic two-band toon flame (no texture, no noise
|
|
3
|
+
// fetch — angular sine licks over an SDF body, same policy as spriteShapes).
|
|
4
|
+
// vfxSystem moves the center uniform each frame; everything else is a pure
|
|
5
|
+
// function of the shared clock.
|
|
6
|
+
|
|
7
|
+
import * as THREE from 'three';
|
|
8
|
+
import { NodeMaterial } from 'three/webgpu';
|
|
9
|
+
import {
|
|
10
|
+
cameraProjectionMatrix, cameraViewMatrix, clamp, Discard, Fn, length, mix, positionLocal,
|
|
11
|
+
sin, smoothstep, step, uniform, uv, vec2, vec4,
|
|
12
|
+
} from 'three/tsl';
|
|
13
|
+
|
|
14
|
+
import { flameLicks } from './spriteShapes.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One pooled projectile core. `sharedUniforms` is the burst backbone's set
|
|
18
|
+
* (uTime + camera billboard basis) so the flame animates on the same clock.
|
|
19
|
+
*/
|
|
20
|
+
export function createProjectileCore({ sharedUniforms }) {
|
|
21
|
+
const u = sharedUniforms;
|
|
22
|
+
const look = {
|
|
23
|
+
uCenter: uniform(new THREE.Vector3()),
|
|
24
|
+
uCoreColor: uniform(new THREE.Color(1.0, 0.95, 0.6)),
|
|
25
|
+
uFlameColor: uniform(new THREE.Color(1.0, 0.45, 0.12)),
|
|
26
|
+
uIntensity: uniform(1.2),
|
|
27
|
+
uSeed: uniform(0),
|
|
28
|
+
uSize: uniform(0.42),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const material = new NodeMaterial();
|
|
32
|
+
material.name = 'VfxProjectileCore';
|
|
33
|
+
material.transparent = true;
|
|
34
|
+
material.blending = THREE.AdditiveBlending;
|
|
35
|
+
material.depthWrite = false;
|
|
36
|
+
material.fog = false;
|
|
37
|
+
|
|
38
|
+
material.vertexNode = Fn(() => {
|
|
39
|
+
// Camera-facing billboard around the CPU-fed center; quad is 1×1 so the
|
|
40
|
+
// world size is uSize · 2 across.
|
|
41
|
+
const position = look.uCenter
|
|
42
|
+
.add(u.uCamRight.mul(positionLocal.x).mul(look.uSize).mul(2.0))
|
|
43
|
+
.add(u.uCamUp.mul(positionLocal.y).mul(look.uSize).mul(2.0));
|
|
44
|
+
return cameraProjectionMatrix.mul(cameraViewMatrix).mul(vec4(position, 1.0));
|
|
45
|
+
})();
|
|
46
|
+
|
|
47
|
+
material.fragmentNode = Fn(() => {
|
|
48
|
+
const p = uv().mul(2.0).sub(1.0).toVar();
|
|
49
|
+
const t = u.uTime.add(look.uSeed.mul(37.0));
|
|
50
|
+
const body = flameLicks(p, t, look.uSeed).toVar();
|
|
51
|
+
Discard(body.lessThan(0.01));
|
|
52
|
+
|
|
53
|
+
// Two hard toon bands: hot core inside, flame skin outside, plus a
|
|
54
|
+
// flicker so the ball feels alive even in a straight throw.
|
|
55
|
+
const hot = smoothstep(0.7, 0.15, length(p.mul(vec2(1.0, 0.9))));
|
|
56
|
+
const banded = mix(look.uFlameColor, look.uCoreColor, step(0.55, hot));
|
|
57
|
+
const flicker = sin(t.mul(11.0)).mul(0.06).add(0.97);
|
|
58
|
+
const alpha = clamp(body, 0.0, 1.0).mul(look.uIntensity).mul(flicker);
|
|
59
|
+
return vec4(banded.mul(alpha), alpha);
|
|
60
|
+
})();
|
|
61
|
+
material.uniforms = look;
|
|
62
|
+
|
|
63
|
+
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
|
|
64
|
+
mesh.name = 'VfxProjectileCore';
|
|
65
|
+
mesh.frustumCulled = false;
|
|
66
|
+
mesh.renderOrder = 7;
|
|
67
|
+
mesh.visible = false;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
mesh,
|
|
71
|
+
uniforms: look,
|
|
72
|
+
/** Re-tints and shows the core for a new projectile. */
|
|
73
|
+
arm({ coreColor, flameColor, coreSize, intensity, seed } = {}) {
|
|
74
|
+
if (Array.isArray(coreColor)) look.uCoreColor.value.setRGB(...coreColor, THREE.SRGBColorSpace);
|
|
75
|
+
if (Array.isArray(flameColor)) look.uFlameColor.value.setRGB(...flameColor, THREE.SRGBColorSpace);
|
|
76
|
+
if (Number.isFinite(coreSize)) look.uSize.value = Math.max(coreSize, 0.01);
|
|
77
|
+
if (Number.isFinite(intensity)) look.uIntensity.value = Math.max(intensity, 0);
|
|
78
|
+
if (Number.isFinite(seed)) look.uSeed.value = seed;
|
|
79
|
+
mesh.visible = true;
|
|
80
|
+
},
|
|
81
|
+
setCenter(position) {
|
|
82
|
+
look.uCenter.value.copy(position);
|
|
83
|
+
},
|
|
84
|
+
reset() {
|
|
85
|
+
mesh.visible = false;
|
|
86
|
+
},
|
|
87
|
+
dispose() {
|
|
88
|
+
mesh.geometry.dispose();
|
|
89
|
+
material.dispose();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|