@triforge/volume-core 0.1.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/dist/PointDensity.d.ts +76 -0
- package/dist/PointDensity.d.ts.map +1 -0
- package/dist/PointDensity.js +122 -0
- package/dist/VolumeBox.d.ts +89 -0
- package/dist/VolumeBox.d.ts.map +1 -0
- package/dist/VolumeBox.js +281 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/package.json +51 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Data3DTexture } from 'three';
|
|
2
|
+
export interface PointDensityOptions {
|
|
3
|
+
/**
|
|
4
|
+
* 3D texture resolution per axis. Default 32.
|
|
5
|
+
* Higher = finer density detail, more GPU memory (res³ × 16 bytes).
|
|
6
|
+
*/
|
|
7
|
+
resolution?: number;
|
|
8
|
+
/**
|
|
9
|
+
* World-space bounding box to map into the texture.
|
|
10
|
+
* Default: [-2,-2,-2] to [2,2,2].
|
|
11
|
+
*/
|
|
12
|
+
boundsMin?: [number, number, number];
|
|
13
|
+
boundsMax?: [number, number, number];
|
|
14
|
+
/**
|
|
15
|
+
* Gaussian splat radius in world units. Default 0.3.
|
|
16
|
+
* Each point smears its density across neighbouring voxels within this radius.
|
|
17
|
+
*/
|
|
18
|
+
radius?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Colour of the density field. Default [1,1,1].
|
|
21
|
+
*/
|
|
22
|
+
color?: [number, number, number];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* PointDensity — builds a live `THREE.Data3DTexture` from a point cloud
|
|
26
|
+
* (particle positions, fluid particles, or any Float32Array of xyz triples).
|
|
27
|
+
*
|
|
28
|
+
* Blender equivalent: the PointDensity shader node — samples a volumetric
|
|
29
|
+
* density field built from object point cloud data to drive surface shading.
|
|
30
|
+
*
|
|
31
|
+
* Usage:
|
|
32
|
+
* 1. Create once: `const pd = new PointDensity({ resolution: 32 })`
|
|
33
|
+
* 2. Call each frame: `pd.update(particleSystem.getPositions())`
|
|
34
|
+
* 3. Inject into a material uniform: `mat.uniforms.uPointDensity = { value: pd.texture }`
|
|
35
|
+
* 4. In your GLSL: `float density = texture(uPointDensity, worldToUV(pos)).r`
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* const pd = new PointDensity({ resolution: 32, radius: 0.4, boundsMin: [-2,-2,-2], boundsMax: [2,2,2] })
|
|
39
|
+
* // in animate():
|
|
40
|
+
* pd.update(fluid.getPositions())
|
|
41
|
+
* mat.uniforms.uPointDensity.value = pd.texture
|
|
42
|
+
*/
|
|
43
|
+
export declare class PointDensity {
|
|
44
|
+
readonly texture: Data3DTexture;
|
|
45
|
+
parameters: {
|
|
46
|
+
resolution: number;
|
|
47
|
+
radius: number;
|
|
48
|
+
color: [number, number, number];
|
|
49
|
+
};
|
|
50
|
+
private readonly _res;
|
|
51
|
+
private readonly _bMin;
|
|
52
|
+
private readonly _bMax;
|
|
53
|
+
private readonly _bSize;
|
|
54
|
+
private readonly _data;
|
|
55
|
+
constructor(opts?: PointDensityOptions);
|
|
56
|
+
/**
|
|
57
|
+
* Rebuild the density texture from a flat Float32Array of xyz positions.
|
|
58
|
+
* Pass `particleSystem.getPositions()` or `FLIPSimulator.getPositions()` directly.
|
|
59
|
+
* Call each frame when particle positions change.
|
|
60
|
+
*/
|
|
61
|
+
update(positions: Float32Array | number[]): void;
|
|
62
|
+
/**
|
|
63
|
+
* Returns a GLSL helper snippet to sample this texture in a ShaderMaterial.
|
|
64
|
+
* Inject into fragmentShader and call `samplePointDensity(worldPos)`.
|
|
65
|
+
*
|
|
66
|
+
* @param uniformName - Name of the sampler3D uniform. Default 'uPointDensity'.
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* Returns a GLSL snippet (for GLSL 3 / WebGL 2) declaring the sampler3D
|
|
70
|
+
* uniform and a helper function to sample it from world position.
|
|
71
|
+
*
|
|
72
|
+
* Requires `glslVersion: THREE.GLSL3` on the ShaderMaterial.
|
|
73
|
+
*/
|
|
74
|
+
glslSampler(uniformName?: string): string;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=PointDensity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PointDensity.d.ts","sourceRoot":"","sources":["../src/PointDensity.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EAGd,MAAM,OAAO,CAAA;AAEd,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IAEnB;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IACpC,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAEpC;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf;;OAEG;IACH,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;CACjC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,YAAY;IACvB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAA;IAE/B,UAAU,EAAE;QACV,UAAU,EAAE,MAAM,CAAA;QAClB,MAAM,EAAM,MAAM,CAAA;QAClB,KAAK,EAAO,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;KACrC,CAAA;IAED,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAa;IAClC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAa;IACnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAa;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAY;IACnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA+B;gBAEzC,IAAI,GAAE,mBAAwB;IA4B1C;;;;OAIG;IACH,MAAM,CAAC,SAAS,EAAE,YAAY,GAAG,MAAM,EAAE,GAAG,IAAI;IAsDhD;;;;;OAKG;IACH;;;;;OAKG;IACH,WAAW,CAAC,WAAW,SAAkB,GAAG,MAAM;CAYnD"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Data3DTexture, RGBAFormat, FloatType, LinearFilter, ClampToEdgeWrapping, Vector3, } from 'three';
|
|
2
|
+
/**
|
|
3
|
+
* PointDensity — builds a live `THREE.Data3DTexture` from a point cloud
|
|
4
|
+
* (particle positions, fluid particles, or any Float32Array of xyz triples).
|
|
5
|
+
*
|
|
6
|
+
* Blender equivalent: the PointDensity shader node — samples a volumetric
|
|
7
|
+
* density field built from object point cloud data to drive surface shading.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* 1. Create once: `const pd = new PointDensity({ resolution: 32 })`
|
|
11
|
+
* 2. Call each frame: `pd.update(particleSystem.getPositions())`
|
|
12
|
+
* 3. Inject into a material uniform: `mat.uniforms.uPointDensity = { value: pd.texture }`
|
|
13
|
+
* 4. In your GLSL: `float density = texture(uPointDensity, worldToUV(pos)).r`
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const pd = new PointDensity({ resolution: 32, radius: 0.4, boundsMin: [-2,-2,-2], boundsMax: [2,2,2] })
|
|
17
|
+
* // in animate():
|
|
18
|
+
* pd.update(fluid.getPositions())
|
|
19
|
+
* mat.uniforms.uPointDensity.value = pd.texture
|
|
20
|
+
*/
|
|
21
|
+
export class PointDensity {
|
|
22
|
+
constructor(opts = {}) {
|
|
23
|
+
const res = opts.resolution ?? 32;
|
|
24
|
+
const bMin = opts.boundsMin ?? [-2, -2, -2];
|
|
25
|
+
const bMax = opts.boundsMax ?? [2, 2, 2];
|
|
26
|
+
this._res = res;
|
|
27
|
+
this._bMin = new Vector3(...bMin);
|
|
28
|
+
this._bMax = new Vector3(...bMax);
|
|
29
|
+
this._bSize = new Vector3().subVectors(this._bMax, this._bMin);
|
|
30
|
+
this._data = new Float32Array(res * res * res * 4);
|
|
31
|
+
this.parameters = {
|
|
32
|
+
resolution: res,
|
|
33
|
+
radius: opts.radius ?? 0.3,
|
|
34
|
+
color: opts.color ?? [1, 1, 1],
|
|
35
|
+
};
|
|
36
|
+
this.texture = new Data3DTexture(this._data, res, res, res);
|
|
37
|
+
this.texture.format = RGBAFormat;
|
|
38
|
+
this.texture.type = FloatType;
|
|
39
|
+
this.texture.minFilter = LinearFilter;
|
|
40
|
+
this.texture.magFilter = LinearFilter;
|
|
41
|
+
this.texture.wrapS = ClampToEdgeWrapping;
|
|
42
|
+
this.texture.wrapT = ClampToEdgeWrapping;
|
|
43
|
+
this.texture.wrapR = ClampToEdgeWrapping;
|
|
44
|
+
this.texture.needsUpdate = true;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Rebuild the density texture from a flat Float32Array of xyz positions.
|
|
48
|
+
* Pass `particleSystem.getPositions()` or `FLIPSimulator.getPositions()` directly.
|
|
49
|
+
* Call each frame when particle positions change.
|
|
50
|
+
*/
|
|
51
|
+
update(positions) {
|
|
52
|
+
const res = this._res;
|
|
53
|
+
const data = this._data;
|
|
54
|
+
const radius = this.parameters.radius;
|
|
55
|
+
const [cr, cg, cb] = this.parameters.color;
|
|
56
|
+
// Clear
|
|
57
|
+
data.fill(0);
|
|
58
|
+
const invSizeX = res / this._bSize.x;
|
|
59
|
+
const invSizeY = res / this._bSize.y;
|
|
60
|
+
const invSizeZ = res / this._bSize.z;
|
|
61
|
+
const r2 = radius * radius;
|
|
62
|
+
const n = Math.floor(positions.length / 3);
|
|
63
|
+
for (let p = 0; p < n; p++) {
|
|
64
|
+
const wx = positions[p * 3];
|
|
65
|
+
const wy = positions[p * 3 + 1];
|
|
66
|
+
const wz = positions[p * 3 + 2];
|
|
67
|
+
// Map to voxel space
|
|
68
|
+
const vx = (wx - this._bMin.x) * invSizeX;
|
|
69
|
+
const vy = (wy - this._bMin.y) * invSizeY;
|
|
70
|
+
const vz = (wz - this._bMin.z) * invSizeZ;
|
|
71
|
+
// Splat into a small neighbourhood
|
|
72
|
+
const vrad = radius * invSizeX; // approx voxel radius
|
|
73
|
+
const r0x = Math.max(0, Math.floor(vx - vrad));
|
|
74
|
+
const r1x = Math.min(res - 1, Math.ceil(vx + vrad));
|
|
75
|
+
const r0y = Math.max(0, Math.floor(vy - vrad));
|
|
76
|
+
const r1y = Math.min(res - 1, Math.ceil(vy + vrad));
|
|
77
|
+
const r0z = Math.max(0, Math.floor(vz - vrad));
|
|
78
|
+
const r1z = Math.min(res - 1, Math.ceil(vz + vrad));
|
|
79
|
+
for (let iz = r0z; iz <= r1z; iz++)
|
|
80
|
+
for (let iy = r0y; iy <= r1y; iy++)
|
|
81
|
+
for (let ix = r0x; ix <= r1x; ix++) {
|
|
82
|
+
const dwx = (ix / invSizeX + this._bMin.x) - wx;
|
|
83
|
+
const dwy = (iy / invSizeY + this._bMin.y) - wy;
|
|
84
|
+
const dwz = (iz / invSizeZ + this._bMin.z) - wz;
|
|
85
|
+
const d2 = dwx * dwx + dwy * dwy + dwz * dwz;
|
|
86
|
+
if (d2 > r2)
|
|
87
|
+
continue;
|
|
88
|
+
const weight = 1 - d2 / r2; // linear falloff
|
|
89
|
+
const idx = (iz * res * res + iy * res + ix) * 4;
|
|
90
|
+
data[idx] += weight * cr;
|
|
91
|
+
data[idx + 1] += weight * cg;
|
|
92
|
+
data[idx + 2] += weight * cb;
|
|
93
|
+
data[idx + 3] += weight;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
this.texture.needsUpdate = true;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Returns a GLSL helper snippet to sample this texture in a ShaderMaterial.
|
|
100
|
+
* Inject into fragmentShader and call `samplePointDensity(worldPos)`.
|
|
101
|
+
*
|
|
102
|
+
* @param uniformName - Name of the sampler3D uniform. Default 'uPointDensity'.
|
|
103
|
+
*/
|
|
104
|
+
/**
|
|
105
|
+
* Returns a GLSL snippet (for GLSL 3 / WebGL 2) declaring the sampler3D
|
|
106
|
+
* uniform and a helper function to sample it from world position.
|
|
107
|
+
*
|
|
108
|
+
* Requires `glslVersion: THREE.GLSL3` on the ShaderMaterial.
|
|
109
|
+
*/
|
|
110
|
+
glslSampler(uniformName = 'uPointDensity') {
|
|
111
|
+
const bMin = this._bMin;
|
|
112
|
+
const bSize = this._bSize;
|
|
113
|
+
return `
|
|
114
|
+
uniform sampler3D ${uniformName};
|
|
115
|
+
vec4 samplePointDensity(vec3 worldPos) {
|
|
116
|
+
vec3 uv = (worldPos - vec3(${bMin.x.toFixed(4)}, ${bMin.y.toFixed(4)}, ${bMin.z.toFixed(4)}))
|
|
117
|
+
/ vec3(${bSize.x.toFixed(4)}, ${bSize.y.toFixed(4)}, ${bSize.z.toFixed(4)});
|
|
118
|
+
uv = clamp(uv, 0.0, 1.0);
|
|
119
|
+
return texture(${uniformName}, uv);
|
|
120
|
+
}`.trim();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Mesh, ShaderMaterial } from 'three';
|
|
2
|
+
export type VolumeType = 'fog' | 'smoke' | 'fire';
|
|
3
|
+
export interface VolumeBoxOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Volume preset.
|
|
6
|
+
* 'fog' — uniform density absorption (Blender: Volume Absorption)
|
|
7
|
+
* 'smoke' — animated noise density scatter (Blender: Volume Scatter + noise)
|
|
8
|
+
* 'fire' — emission-based fire with density noise (Blender: Principled Volume, fire preset)
|
|
9
|
+
* Default 'fog'.
|
|
10
|
+
*/
|
|
11
|
+
type?: VolumeType;
|
|
12
|
+
/** World-space box size [x,y,z]. Default [2,2,2]. */
|
|
13
|
+
size?: [number, number, number];
|
|
14
|
+
/** Overall density multiplier. Default 1. */
|
|
15
|
+
density?: number;
|
|
16
|
+
/** Primary scatter/absorption colour (hex string). Default '#aaccff' for fog, '#888' for smoke, '#ff6600' for fire. */
|
|
17
|
+
color?: string;
|
|
18
|
+
/** Emission colour for fire type (hex string). Default '#ff4400'. */
|
|
19
|
+
emissionColor?: string;
|
|
20
|
+
/** Emission strength (fire only). Default 3. */
|
|
21
|
+
emissionStrength?: number;
|
|
22
|
+
/** Noise scale — spatial frequency of heterogeneous density. Default 1.5. */
|
|
23
|
+
noiseScale?: number;
|
|
24
|
+
/** Noise animation speed. Default 0.3. */
|
|
25
|
+
noiseSpeed?: number;
|
|
26
|
+
/** Number of ray-march steps — quality vs performance. Default 48. */
|
|
27
|
+
steps?: number;
|
|
28
|
+
/** Henyey-Greenstein anisotropy [-1, 1]. 0=isotropic. Default 0. */
|
|
29
|
+
anisotropy?: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* VolumeBox — axis-aligned box filled with volumetric media.
|
|
33
|
+
* Blender equivalents: Volume Absorption · Volume Scatter · Principled Volume.
|
|
34
|
+
*
|
|
35
|
+
* Communicates with the scene as a THREE.Mesh — add it to the scene like any mesh.
|
|
36
|
+
* All animation parameters live in `parameters` for GSAP / st-keyframe compatibility.
|
|
37
|
+
*
|
|
38
|
+
* Usage:
|
|
39
|
+
* ```typescript
|
|
40
|
+
* const fog = new VolumeBox({ type: 'fog', density: 0.8, color: '#aaccff' })
|
|
41
|
+
* scene.add(fog.mesh)
|
|
42
|
+
*
|
|
43
|
+
* // Animate density
|
|
44
|
+
* gsap.to(fog.parameters, { density: 0.3, duration: 2 })
|
|
45
|
+
* // Update uniforms each frame:
|
|
46
|
+
* fog.tick(clock.getElapsedTime())
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export declare class VolumeBox {
|
|
50
|
+
readonly mesh: Mesh;
|
|
51
|
+
readonly material: ShaderMaterial;
|
|
52
|
+
/** All scalar parameters — GSAP / st-keyframe compatible. */
|
|
53
|
+
parameters: {
|
|
54
|
+
density: number;
|
|
55
|
+
noiseScale: number;
|
|
56
|
+
noiseSpeed: number;
|
|
57
|
+
steps: number;
|
|
58
|
+
anisotropy: number;
|
|
59
|
+
emissionStrength: number;
|
|
60
|
+
colorR: number;
|
|
61
|
+
colorG: number;
|
|
62
|
+
colorB: number;
|
|
63
|
+
emissionR: number;
|
|
64
|
+
emissionG: number;
|
|
65
|
+
emissionB: number;
|
|
66
|
+
};
|
|
67
|
+
private readonly _type;
|
|
68
|
+
constructor(opts?: VolumeBoxOptions);
|
|
69
|
+
/**
|
|
70
|
+
* Call once per frame to sync animated parameters to GPU uniforms.
|
|
71
|
+
* @param time — `clock.getElapsedTime()`
|
|
72
|
+
*/
|
|
73
|
+
private _invModel;
|
|
74
|
+
private _worldCam;
|
|
75
|
+
tick(time: number): void;
|
|
76
|
+
/**
|
|
77
|
+
* Set the camera world-space position so the ray-march shader knows the
|
|
78
|
+
* ray origin. Call each frame after the camera has been moved:
|
|
79
|
+
* vol.setCameraPosition(camera.position.x, camera.position.y, camera.position.z)
|
|
80
|
+
*
|
|
81
|
+
* Converts to object space automatically using the mesh's world matrix.
|
|
82
|
+
*/
|
|
83
|
+
setCameraPosition(x: number, y: number, z: number): void;
|
|
84
|
+
dispose(): void;
|
|
85
|
+
private _typeIndex;
|
|
86
|
+
private static _vert;
|
|
87
|
+
private static _frag;
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=VolumeBox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"VolumeBox.d.ts","sourceRoot":"","sources":["../src/VolumeBox.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,IAAI,EAAe,cAAc,EAIlC,MAAM,OAAO,CAAA;AAEd,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,OAAO,GAAG,MAAM,CAAA;AAEjD,MAAM,WAAW,gBAAgB;IAC/B;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,UAAU,CAAA;IAEjB,qDAAqD;IACrD,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAE/B,6CAA6C;IAC7C,OAAO,CAAC,EAAE,MAAM,CAAA;IAEhB,uHAAuH;IACvH,KAAK,CAAC,EAAE,MAAM,CAAA;IAEd,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAA;IAEtB,gDAAgD;IAChD,gBAAgB,CAAC,EAAE,MAAM,CAAA;IAEzB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,MAAM,CAAA;IAEnB,0CAA0C;IAC1C,UAAU,CAAC,EAAE,MAAM,CAAA;IAEnB,sEAAsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAA;IAEd,oEAAoE;IACpE,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,SAAS;IACpB,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;IACnB,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAA;IAEjC,6DAA6D;IAC7D,UAAU,EAAE;QACV,OAAO,EAAW,MAAM,CAAA;QACxB,UAAU,EAAQ,MAAM,CAAA;QACxB,UAAU,EAAQ,MAAM,CAAA;QACxB,KAAK,EAAa,MAAM,CAAA;QACxB,UAAU,EAAQ,MAAM,CAAA;QACxB,gBAAgB,EAAE,MAAM,CAAA;QACxB,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;QAC9C,SAAS,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KACxD,CAAA;IAED,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAY;gBAEtB,IAAI,GAAE,gBAAqB;IAmDvC;;;OAGG;IACH,OAAO,CAAC,SAAS,CAAgB;IACjC,OAAO,CAAC,SAAS,CAAgB;IAEjC,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAcxB;;;;;;OAMG;IACH,iBAAiB,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI;IASxD,OAAO,IAAI,IAAI;IAKf,OAAO,CAAC,UAAU;IAMlB,OAAO,CAAC,MAAM,CAAC,KAAK,CAUnB;IAID,OAAO,CAAC,MAAM,CAAC,KAAK,CAgKnB;CACF"}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { Mesh, BoxGeometry, ShaderMaterial, Vector3, Matrix4, Color, AdditiveBlending, NormalBlending, BackSide, } from 'three';
|
|
2
|
+
/**
|
|
3
|
+
* VolumeBox — axis-aligned box filled with volumetric media.
|
|
4
|
+
* Blender equivalents: Volume Absorption · Volume Scatter · Principled Volume.
|
|
5
|
+
*
|
|
6
|
+
* Communicates with the scene as a THREE.Mesh — add it to the scene like any mesh.
|
|
7
|
+
* All animation parameters live in `parameters` for GSAP / st-keyframe compatibility.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* ```typescript
|
|
11
|
+
* const fog = new VolumeBox({ type: 'fog', density: 0.8, color: '#aaccff' })
|
|
12
|
+
* scene.add(fog.mesh)
|
|
13
|
+
*
|
|
14
|
+
* // Animate density
|
|
15
|
+
* gsap.to(fog.parameters, { density: 0.3, duration: 2 })
|
|
16
|
+
* // Update uniforms each frame:
|
|
17
|
+
* fog.tick(clock.getElapsedTime())
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export class VolumeBox {
|
|
21
|
+
constructor(opts = {}) {
|
|
22
|
+
/**
|
|
23
|
+
* Call once per frame to sync animated parameters to GPU uniforms.
|
|
24
|
+
* @param time — `clock.getElapsedTime()`
|
|
25
|
+
*/
|
|
26
|
+
this._invModel = new Matrix4();
|
|
27
|
+
this._worldCam = new Vector3();
|
|
28
|
+
this._type = opts.type ?? 'fog';
|
|
29
|
+
const defaultColor = this._type === 'fire' ? '#ff6600'
|
|
30
|
+
: this._type === 'smoke' ? '#888888'
|
|
31
|
+
: '#aaccff';
|
|
32
|
+
const colorHex = opts.color ?? defaultColor;
|
|
33
|
+
const emitHex = opts.emissionColor ?? '#ff4400';
|
|
34
|
+
const col = new Color(colorHex);
|
|
35
|
+
const emit = new Color(emitHex);
|
|
36
|
+
this.parameters = {
|
|
37
|
+
density: opts.density ?? 1,
|
|
38
|
+
noiseScale: opts.noiseScale ?? 1.5,
|
|
39
|
+
noiseSpeed: opts.noiseSpeed ?? 0.3,
|
|
40
|
+
steps: opts.steps ?? 48,
|
|
41
|
+
anisotropy: opts.anisotropy ?? 0,
|
|
42
|
+
emissionStrength: opts.emissionStrength ?? 3,
|
|
43
|
+
colorR: col.r, colorG: col.g, colorB: col.b,
|
|
44
|
+
emissionR: emit.r, emissionG: emit.g, emissionB: emit.b,
|
|
45
|
+
};
|
|
46
|
+
const [sx, sy, sz] = opts.size ?? [2, 2, 2];
|
|
47
|
+
const geo = new BoxGeometry(sx, sy, sz);
|
|
48
|
+
this.material = new ShaderMaterial({
|
|
49
|
+
uniforms: {
|
|
50
|
+
uDensity: { value: this.parameters.density },
|
|
51
|
+
uNoiseScale: { value: this.parameters.noiseScale },
|
|
52
|
+
uNoiseSpeed: { value: this.parameters.noiseSpeed },
|
|
53
|
+
uSteps: { value: this.parameters.steps },
|
|
54
|
+
uAnisotropy: { value: this.parameters.anisotropy },
|
|
55
|
+
uEmissionStrength: { value: this.parameters.emissionStrength },
|
|
56
|
+
uColor: { value: new Vector3(col.r, col.g, col.b) },
|
|
57
|
+
uEmission: { value: new Vector3(emit.r, emit.g, emit.b) },
|
|
58
|
+
uTime: { value: 0 },
|
|
59
|
+
uBoxSize: { value: new Vector3(sx, sy, sz) },
|
|
60
|
+
uVolumeType: { value: this._typeIndex() },
|
|
61
|
+
uCameraLocalPos: { value: new Vector3() },
|
|
62
|
+
},
|
|
63
|
+
vertexShader: VolumeBox._vert,
|
|
64
|
+
fragmentShader: VolumeBox._frag,
|
|
65
|
+
side: BackSide,
|
|
66
|
+
transparent: true,
|
|
67
|
+
depthWrite: false,
|
|
68
|
+
blending: this._type === 'fire' ? AdditiveBlending : NormalBlending,
|
|
69
|
+
});
|
|
70
|
+
this.mesh = new Mesh(geo, this.material);
|
|
71
|
+
}
|
|
72
|
+
tick(time) {
|
|
73
|
+
const u = this.material.uniforms;
|
|
74
|
+
const p = this.parameters;
|
|
75
|
+
u['uTime'].value = time;
|
|
76
|
+
u['uDensity'].value = p.density;
|
|
77
|
+
u['uNoiseScale'].value = p.noiseScale;
|
|
78
|
+
u['uNoiseSpeed'].value = p.noiseSpeed;
|
|
79
|
+
u['uSteps'].value = Math.max(8, Math.round(p.steps));
|
|
80
|
+
u['uAnisotropy'].value = p.anisotropy;
|
|
81
|
+
u['uEmissionStrength'].value = p.emissionStrength;
|
|
82
|
+
u['uColor'].value.set(p.colorR, p.colorG, p.colorB);
|
|
83
|
+
u['uEmission'].value.set(p.emissionR, p.emissionG, p.emissionB);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Set the camera world-space position so the ray-march shader knows the
|
|
87
|
+
* ray origin. Call each frame after the camera has been moved:
|
|
88
|
+
* vol.setCameraPosition(camera.position.x, camera.position.y, camera.position.z)
|
|
89
|
+
*
|
|
90
|
+
* Converts to object space automatically using the mesh's world matrix.
|
|
91
|
+
*/
|
|
92
|
+
setCameraPosition(x, y, z) {
|
|
93
|
+
this.mesh.updateWorldMatrix(true, false);
|
|
94
|
+
this._invModel.copy(this.mesh.matrixWorld).invert();
|
|
95
|
+
this._worldCam.set(x, y, z);
|
|
96
|
+
this.material.uniforms['uCameraLocalPos'].value
|
|
97
|
+
.copy(this._worldCam)
|
|
98
|
+
.applyMatrix4(this._invModel);
|
|
99
|
+
}
|
|
100
|
+
dispose() {
|
|
101
|
+
this.mesh.geometry.dispose();
|
|
102
|
+
this.material.dispose();
|
|
103
|
+
}
|
|
104
|
+
_typeIndex() {
|
|
105
|
+
return this._type === 'fog' ? 0 : this._type === 'smoke' ? 1 : 2;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// ── Vertex shader ─────────────────────────────────────────────────────────
|
|
109
|
+
VolumeBox._vert = `
|
|
110
|
+
varying vec3 vWorldPos;
|
|
111
|
+
varying vec3 vLocalPos;
|
|
112
|
+
|
|
113
|
+
void main() {
|
|
114
|
+
vec4 world = modelMatrix * vec4(position, 1.0);
|
|
115
|
+
vWorldPos = world.xyz;
|
|
116
|
+
vLocalPos = position;
|
|
117
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
118
|
+
}
|
|
119
|
+
`;
|
|
120
|
+
// ── Fragment shader ───────────────────────────────────────────────────────
|
|
121
|
+
VolumeBox._frag = `
|
|
122
|
+
precision highp float;
|
|
123
|
+
|
|
124
|
+
uniform float uDensity;
|
|
125
|
+
uniform float uNoiseScale;
|
|
126
|
+
uniform float uNoiseSpeed;
|
|
127
|
+
uniform float uSteps;
|
|
128
|
+
uniform float uAnisotropy;
|
|
129
|
+
uniform float uEmissionStrength;
|
|
130
|
+
uniform vec3 uColor;
|
|
131
|
+
uniform vec3 uEmission;
|
|
132
|
+
uniform float uTime;
|
|
133
|
+
uniform vec3 uBoxSize;
|
|
134
|
+
uniform int uVolumeType;
|
|
135
|
+
uniform vec3 uCameraLocalPos;
|
|
136
|
+
|
|
137
|
+
varying vec3 vWorldPos;
|
|
138
|
+
varying vec3 vLocalPos;
|
|
139
|
+
|
|
140
|
+
// ── Noise ────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
float hash(vec3 p) {
|
|
143
|
+
p = fract(p * vec3(127.1, 311.7, 74.7));
|
|
144
|
+
p += dot(p, p.zyx + 31.32);
|
|
145
|
+
return fract((p.x + p.y) * p.z);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
float noise3(vec3 p) {
|
|
149
|
+
vec3 i = floor(p);
|
|
150
|
+
vec3 f = fract(p);
|
|
151
|
+
vec3 u = f * f * (3.0 - 2.0 * f);
|
|
152
|
+
return mix(
|
|
153
|
+
mix(mix(hash(i), hash(i + vec3(1,0,0)), u.x),
|
|
154
|
+
mix(hash(i+vec3(0,1,0)),hash(i + vec3(1,1,0)), u.x), u.y),
|
|
155
|
+
mix(mix(hash(i+vec3(0,0,1)),hash(i+vec3(1,0,1)), u.x),
|
|
156
|
+
mix(hash(i+vec3(0,1,1)),hash(i + vec3(1,1,1)), u.x), u.y), u.z);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
float fbm(vec3 p) {
|
|
160
|
+
float v = 0.0, a = 0.5;
|
|
161
|
+
for (int i = 0; i < 4; i++) {
|
|
162
|
+
v += a * noise3(p);
|
|
163
|
+
p = p * 2.1 + vec3(1.7, 9.2, 3.4);
|
|
164
|
+
a *= 0.5;
|
|
165
|
+
}
|
|
166
|
+
return v;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Phase function (un-normalized HG — visual range ~0.3..3.0) ──────────
|
|
170
|
+
// Omitting the 1/(4π) denominator keeps scattered values in a visible range.
|
|
171
|
+
|
|
172
|
+
float phase(float cosTheta, float g) {
|
|
173
|
+
float g2 = g * g;
|
|
174
|
+
return (1.0 - g2) / pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Ray–AABB intersection ─────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
bool intersectBox(vec3 ro, vec3 rd, vec3 halfSize, out float tMin, out float tMax) {
|
|
180
|
+
vec3 inv = 1.0 / rd;
|
|
181
|
+
vec3 tA = (-halfSize - ro) * inv;
|
|
182
|
+
vec3 tB = ( halfSize - ro) * inv;
|
|
183
|
+
vec3 t0 = min(tA, tB);
|
|
184
|
+
vec3 t1 = max(tA, tB);
|
|
185
|
+
tMin = max(max(t0.x, t0.y), t0.z);
|
|
186
|
+
tMax = min(min(t1.x, t1.y), t1.z);
|
|
187
|
+
return tMax > max(tMin, 0.0);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Density sampling ─────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
float sampleDensity(vec3 localPos) {
|
|
193
|
+
if (uVolumeType == 0) {
|
|
194
|
+
// Fog: uniform with soft edge falloff
|
|
195
|
+
vec3 d = abs(localPos) / (uBoxSize * 0.5);
|
|
196
|
+
float edge = 1.0 - smoothstep(0.8, 1.0, max(max(d.x, d.y), d.z));
|
|
197
|
+
return uDensity * edge;
|
|
198
|
+
} else if (uVolumeType == 1) {
|
|
199
|
+
// Smoke: animated noise
|
|
200
|
+
vec3 p = localPos * uNoiseScale + vec3(0.0, -uTime * uNoiseSpeed, 0.0);
|
|
201
|
+
float n = fbm(p);
|
|
202
|
+
float rim = 1.0 - smoothstep(0.35, 0.5, length(localPos / (uBoxSize * 0.5)));
|
|
203
|
+
return max(0.0, (n - 0.35) * 2.0) * uDensity * rim;
|
|
204
|
+
} else {
|
|
205
|
+
// Fire: density concentrated at base, thinning toward top
|
|
206
|
+
vec3 hs = uBoxSize * 0.5;
|
|
207
|
+
float vy = (localPos.y + hs.y) / (uBoxSize.y); // 0=bottom, 1=top
|
|
208
|
+
vec3 p = localPos * uNoiseScale + vec3(0.0, -uTime * uNoiseSpeed * 2.0, 0.0);
|
|
209
|
+
float n = fbm(p);
|
|
210
|
+
float rim = 1.0 - smoothstep(0.3, 0.5, length(localPos.xz / hs.xz));
|
|
211
|
+
return max(0.0, (n - 0.2) * (1.0 - vy * 0.9)) * uDensity * rim;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Emission colour at position ───────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
vec3 sampleEmission(vec3 localPos, float density) {
|
|
218
|
+
if (uVolumeType != 2) return vec3(0.0);
|
|
219
|
+
// Fire colour ramp: black → orange → yellow → white
|
|
220
|
+
vec3 hs = uBoxSize * 0.5;
|
|
221
|
+
float vy = (localPos.y + hs.y) / uBoxSize.y;
|
|
222
|
+
float t = clamp(density * 1.5, 0.0, 1.0);
|
|
223
|
+
vec3 c0 = vec3(0.0);
|
|
224
|
+
vec3 c1 = uColor; // base fire colour (orange)
|
|
225
|
+
vec3 c2 = uEmission; // hot core colour (yellow-white)
|
|
226
|
+
vec3 col = t < 0.5 ? mix(c0, c1, t * 2.0) : mix(c1, c2, (t - 0.5) * 2.0);
|
|
227
|
+
return col * uEmissionStrength * (1.0 - vy * 0.5);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
void main() {
|
|
231
|
+
// Camera position is already in local (object) space — computed on CPU
|
|
232
|
+
vec3 ro = uCameraLocalPos;
|
|
233
|
+
vec3 rd = normalize(vLocalPos - ro);
|
|
234
|
+
vec3 halfS = uBoxSize * 0.5;
|
|
235
|
+
|
|
236
|
+
float tMin, tMax;
|
|
237
|
+
if (!intersectBox(ro, rd, halfS, tMin, tMax)) {
|
|
238
|
+
discard;
|
|
239
|
+
}
|
|
240
|
+
tMin = max(tMin, 0.0);
|
|
241
|
+
|
|
242
|
+
// Simple sun direction (fixed — enough for visual quality)
|
|
243
|
+
vec3 sunDir = normalize(vec3(0.5, 1.0, 0.3));
|
|
244
|
+
float cosTheta = dot(rd, sunDir);
|
|
245
|
+
float ph = phase(cosTheta, uAnisotropy);
|
|
246
|
+
|
|
247
|
+
float stepSize = (tMax - tMin) / uSteps;
|
|
248
|
+
vec3 transmit = vec3(1.0);
|
|
249
|
+
vec3 scattered = vec3(0.0);
|
|
250
|
+
vec3 emitted = vec3(0.0);
|
|
251
|
+
|
|
252
|
+
for (int i = 0; i < 128; i++) {
|
|
253
|
+
if (float(i) >= uSteps) break;
|
|
254
|
+
float t = tMin + (float(i) + 0.5) * stepSize;
|
|
255
|
+
vec3 localP = ro + rd * t;
|
|
256
|
+
float dens = sampleDensity(localP);
|
|
257
|
+
if (dens < 0.0001) continue;
|
|
258
|
+
|
|
259
|
+
// Beer–Lambert extinction
|
|
260
|
+
vec3 sigma_t = uColor * dens;
|
|
261
|
+
vec3 extinction = exp(-sigma_t * stepSize);
|
|
262
|
+
vec3 weight = transmit * (vec3(1.0) - extinction);
|
|
263
|
+
|
|
264
|
+
// In-scatter: sun (directional) + ambient fill
|
|
265
|
+
float ambient = 0.35;
|
|
266
|
+
scattered += weight * uColor * (ph * 0.45 + ambient);
|
|
267
|
+
|
|
268
|
+
// Emission (fire)
|
|
269
|
+
emitted += weight * sampleEmission(localP, dens);
|
|
270
|
+
|
|
271
|
+
transmit *= extinction;
|
|
272
|
+
if (max(transmit.r, max(transmit.g, transmit.b)) < 0.01) break;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
float alpha = 1.0 - (transmit.r + transmit.g + transmit.b) / 3.0;
|
|
276
|
+
vec3 color = scattered + emitted;
|
|
277
|
+
|
|
278
|
+
if (alpha < 0.001) discard;
|
|
279
|
+
gl_FragColor = vec4(color, alpha);
|
|
280
|
+
}
|
|
281
|
+
`;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAElE,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAA"}
|
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@triforge/volume-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Volumetric fog, smoke and fire rendering for Three.js — Blender Volume shaders",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"package.json"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"dev": "tsc --watch",
|
|
21
|
+
"test": "node test/run-tests.js"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"volume",
|
|
25
|
+
"fog",
|
|
26
|
+
"smoke",
|
|
27
|
+
"fire",
|
|
28
|
+
"raymarching",
|
|
29
|
+
"three.js",
|
|
30
|
+
"blender"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"three": ">=0.128.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/three": "^0.156.0",
|
|
38
|
+
"three": "^0.156.0",
|
|
39
|
+
"typescript": "^5.3.0"
|
|
40
|
+
},
|
|
41
|
+
"author": "Aftab Ibrahim Kazi",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/AftabIbrahimKazi/triforge.git",
|
|
45
|
+
"directory": "st-volume-core"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/AftabIbrahimKazi/triforge#readme",
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
}
|
|
51
|
+
}
|