@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.
@@ -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
+ `;
@@ -0,0 +1,5 @@
1
+ export { VolumeBox } from './VolumeBox.js';
2
+ export type { VolumeBoxOptions, VolumeType } from './VolumeBox.js';
3
+ export { PointDensity } from './PointDensity.js';
4
+ export type { PointDensityOptions } from './PointDensity.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -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
@@ -0,0 +1,2 @@
1
+ export { VolumeBox } from './VolumeBox.js';
2
+ export { PointDensity } from './PointDensity.js';
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
+ }