diff --git a/examples/files.json b/examples/files.json index 3a51bc11df9da8..64fe864b0ec195 100644 --- a/examples/files.json +++ b/examples/files.json @@ -141,6 +141,7 @@ "webgl_materials_envmaps_exr", "webgl_materials_envmaps_groundprojected", "webgl_materials_envmaps_hdr", + "webgl_materials_fluidsim", "webgl_materials_matcap", "webgl_materials_normalmap", "webgl_materials_normalmap_object_space", diff --git a/examples/jsm/objects/FluidSimulator.js b/examples/jsm/objects/FluidSimulator.js new file mode 100644 index 00000000000000..909e70e207a9ff --- /dev/null +++ b/examples/jsm/objects/FluidSimulator.js @@ -0,0 +1,506 @@ +import { DataTexture, FloatType, Mesh, PlaneGeometry, Raycaster, RGBAFormat, ShaderMaterial, Vector2, Vector3, WebGLRenderTarget } from 'three'; +import { FullScreenQuad } from '../Addons.js'; +import { AdvectVelocityShader, ClearShader, CurlShader, DivergenceShader, GradientSubtractShader, PressureShader, SplatShader, VorticityShader } from '../shaders/FluidSimulationShaders.js'; + +/** @typedef {import('three').Object3D} Object3D */ +/** @typedef {import('three').WebGLRenderer} WebGLRenderer */ + + +function mix( ...configs ) { + + return configs.reduce( ( acc, curr ) => ( { + ...acc, + ...curr, + uniforms: { ...acc.uniforms, ...curr.uniforms }, + } ), {} ); + +} + +export class FluidSimulator extends Mesh { + + get splatForce() { + + return this.splat.uniforms.splatForce.value; + + } + set splatForce( v ) { + + this.splat.uniforms.splatForce.value = v; + + } + + get splatThickness() { + + return this.splat.uniforms.thickness.value; + + } + set splatThickness( v ) { + + this.splat.uniforms.thickness.value = v; + + } + get vorticityInfluence() { + + return this.curl.uniforms.vorticityInfluence.value; + + } + set vorticityInfluence( v ) { + + this.curl.uniforms.vorticityInfluence.value = v; + + } + + get swirlIntensity() { + + return this.vorticity.uniforms.curl.value; + + } + set swirlIntensity( v ) { + + this.vorticity.uniforms.curl.value = v; + + } + + get pressure() { + + return this.clearShader.uniforms.value.value; + + } + set pressure( v ) { + + this.clearShader.uniforms.value.value = v; + + } + + + /** + * The texture representin the liquid. To be used as displacementMap + */ + get elevationTexture() { + + return this.dyeRT.texture; + + } + + /** + * + * @param {WebGLRenderer} renderer + * @param {number} width + * @param {number} height + * @param {number} resolution + * @param {number} maxTrackedObjects + */ + constructor( renderer, width, height, resolution = 100, maxTrackedObjects = 1 ) { + + const aspect = height / width; + const planeGeo = new PlaneGeometry( 1, aspect, resolution, Math.round( resolution * aspect ) ); + planeGeo.rotateX( - Math.PI / 2 ); + + super( planeGeo ); + + this.velocityDissipation = 0.283; + this.densityDissipation = 0.138; + this.pressureIterations = 39; + + /** + * @private + */ + this.t = 0; + + /** + * @private + */ + this.tmp = new Vector3(); + + /** + * @private + */ + this.tmp2 = new Vector3(); + + /** + * @private + */ + this.currentRT = new WebGLRenderTarget( width, height, { type: FloatType } ); + + /** + * @private + */ + this.nextRT = new WebGLRenderTarget( width, height, { type: FloatType } ); + + /** + * @private + */ + this.dyeRT = new WebGLRenderTarget( width, height, { type: FloatType } ); + + /** + * @private + */ + this.nextDyeRT = new WebGLRenderTarget( width, height, { type: FloatType } ); + + // 4 components per object: current.x, current.y, prev.x, prev.y + /** + * @private + */ + this.objectDataArray = new Float32Array( maxTrackedObjects * 4 ); + + // 3. Create the DataTexture + /** + * @private + */ + this.objectDataTexture = new DataTexture( + this.objectDataArray, + maxTrackedObjects, // width + 1, // height + RGBAFormat, + FloatType + ); + + /** + * @type { {target?:Object3D, index:number}[] } + * @private + */ + this.tracking = new Array( maxTrackedObjects ).fill( 0 ).map( ( _, index ) => ( { target: undefined, index } ) ); + + /** + * @private + */ + this.quad = new FullScreenQuad(); + + /** + * @private + */ + this.raycaster = new Raycaster(); + + /** + * @private + */ + this.renderer = renderer; + + const texel = { + uniforms: { + texelSize: { + value: new Vector2( 1 / width, 1 / height ) + } + } + }; + + const gl = renderer.getContext(); + + /** + * @private + */ + this.supportLinearFiltering = !! gl.getExtension( 'OES_texture_half_float_linear' ); + + // ----- shaders used to simulate the liquid ----- + + /** + * @private + */ + this.splat = new ShaderMaterial( mix( SplatShader, texel ) ); //new SplatShader( texel, objectCount, aspect ); + + + /** + * @private + */ + this.curl = new ShaderMaterial( mix( CurlShader, texel ) ); + + /** + * @private + */ + this.vorticity = new ShaderMaterial( mix( VorticityShader, texel ) ); + + /** + * @private + */ + this.divergenceShader = new ShaderMaterial( mix( DivergenceShader, texel ) ); + + /** + * @private + */ + this.clearShader = new ShaderMaterial( mix( ClearShader, texel ) ); + + /** + * @private + */ + this.pressureShader = new ShaderMaterial( mix( PressureShader, texel ) ); + + /** + * @private + */ + this.gradientShader = new ShaderMaterial( mix( GradientSubtractShader, texel ) ); + + /** + * @private + */ + this.advectionShader = new ShaderMaterial( mix( AdvectVelocityShader, texel, { uniforms: { dyeTexelSize: texel.uniforms.texelSize } }, { defines: { MANUAL_FILTERING: this.supportLinearFiltering } } ) ); + + } + + /** + * + * @param {Object3D} object + */ + track( object ) { + + const freeSlot = this.tracking.find( slot=>! slot.target ); + if ( ! freeSlot ) { + + throw new Error( 'No room for tracking, all slots taken!' ); + + } + + // hacer un raycast desde la posision del objeto hacia abajo + // averiguar el UV donde nos pega + // setear ese valor como nuestra posision + + freeSlot.target = object; + + } + + /** + * @param {Object3D} object + */ + untrack( object ) { + + this.tracking.forEach( t=> { + + if ( t.target == object ) { + + t.target = undefined; + + } + + } ); + + } + + /** + * Update the positions... we use the UVs as the positions. We cast a ray from the objects to the surface simulating the liquid + * and calculate the UV that is below the object. + * @private + * @param {Object3D} mesh + */ + updatePositions( mesh ) { + + // update objects positions.... + this.tracking.forEach( obj => { + + const i = obj.index; + + if ( ! obj.target ) { + + this.objectDataArray[ i * 4 + 0 ] = 0; + this.objectDataArray[ i * 4 + 1 ] = 0; + this.objectDataArray[ i * 4 + 2 ] = 0; + this.objectDataArray[ i * 4 + 3 ] = 0; + return; + + } + + + + + this.tmp.set( 0, 1, 0 ); //<--- assuming the origin ob the objects is at the bottom of the models. + const wpos = obj.target.localToWorld( this.tmp ); + + this.tmp2.copy( wpos ); + + const rpos = mesh.worldToLocal( this.tmp2 ); + rpos.y = 0; // this will put the position at the surface of the mesh + + mesh.localToWorld( rpos ); // this way we point at the surface of the mesh. + + + this.raycaster.set( wpos, rpos.sub( wpos ).normalize() ); + + const hit = this.raycaster.intersectObject( mesh, true ); + + if ( hit.length ) { + + const uv = hit[ 0 ].uv; // <--- UV under the object + + if ( uv ) { + + // old positions... + this.objectDataArray[ i * 4 + 2 ] = this.objectDataArray[ i * 4 + 0 ]; + this.objectDataArray[ i * 4 + 3 ] = this.objectDataArray[ i * 4 + 1 ]; + + // new positions... + this.objectDataArray[ i * 4 + 0 ] = uv.x; + this.objectDataArray[ i * 4 + 1 ] = uv.y; + + } + + } + + } ); + + this.objectDataTexture.needsUpdate = true; + + } + + /** + * Renders the material into the next render texture and then swaps them so the new currentRT is the one that was generated by the material. + * @private + * @param {ShaderMaterial} material + */ + blit( material ) { + + this.renderer.setRenderTarget( this.nextRT ); + this.quad.material = material; + this.quad.render( this.renderer ); + + //swap + [ this.currentRT, this.nextRT ] = [ this.nextRT, this.currentRT ]; + + } + + /** + * @private + * @param {ShaderMaterial} material + */ + blitDye( material ) { + + this.renderer.setRenderTarget( this.nextDyeRT ); + this.quad.material = material; + this.quad.render( this.renderer ); + + //swap + [ this.dyeRT, this.nextDyeRT ] = [ this.nextDyeRT, this.dyeRT ]; + + } + + /** + * @private + * @param {number} delta + */ + update( delta ) { + + this.t += delta; + + this.updatePositions( this ); + + // 1. add new velocities based on objects movement + this.splat.uniforms.objectData.value = this.objectDataTexture; + this.splat.uniforms.uTarget.value = this.currentRT.texture; + this.splat.uniforms.splatVelocity.value = true; + + this.blit( this.splat ); + + // add colors + this.splat.uniforms.objectData.value = this.objectDataTexture; + this.splat.uniforms.uTarget.value = this.dyeRT.texture; + this.splat.uniforms.splatVelocity.value = false; + + this.blitDye( this.splat ); + + // 2. vorticity : will be put into the alpha channel... + this.curl.uniforms.uVelocity.value = this.currentRT.texture; + this.blit( this.curl ); + + // 3. apply vorticity forces + this.vorticity.uniforms.uVelocityAndCurl.value = this.currentRT.texture; + this.vorticity.uniforms.dt.value = delta; + this.blit( this.vorticity ); + + // 4. divergence + this.divergenceShader.uniforms.uVelocity.value = this.currentRT.texture; + this.blit( this.divergenceShader ); + + // 5. clear pressure + this.clearShader.uniforms.uTexture.value = this.currentRT.texture; + this.blit( this.clearShader ); + + // 6. calculates and updates pressure + + for ( let i = 0; i < this.pressureIterations; i ++ ) { + + this.pressureShader.uniforms.uPressureWithDivergence.value = this.currentRT.texture; + this.blit( this.pressureShader ); + + } + + // 7. Gradient + this.gradientShader.uniforms.uPressureWithVelocity.value = this.currentRT.texture; + this.blit( this.gradientShader ); + + // 8. Advect velocity + this.advectionShader.uniforms.dt.value = delta; + + this.advectionShader.uniforms.uVelocity.value = this.currentRT.texture; + this.advectionShader.uniforms.uSource.value = this.currentRT.texture; + this.advectionShader.uniforms.sourceIsVelocity.value = true; + this.advectionShader.uniforms.dissipation.value = this.velocityDissipation; //VELOCITY_DISSIPATION + this.blit( this.advectionShader ); + + // 8. Advect dye / color + this.advectionShader.uniforms.uVelocity.value = this.currentRT.texture; + this.advectionShader.uniforms.uSource.value = this.dyeRT.texture; + this.advectionShader.uniforms.sourceIsVelocity.value = false; + this.advectionShader.uniforms.dissipation.value = this.densityDissipation; //DENSITY_DISSIPATION + this.blitDye( this.advectionShader ); + + + + this.renderer.setRenderTarget( null ); + //this.map = this.dyeRT.texture; + //this.displacementMap = this.dyeRT.texture; + + } + + fixMaterial( material ) { + + material.onBeforeCompile = shader => { + + // Pass UV and world position to fragment shader + shader.vertexShader = shader.vertexShader + .replace( + '#include ', + `#include + varying vec2 vUv; + varying vec3 vWorldPos;` + ) + .replace( + '#include ', + `#include + vUv = uv;` + ) + .replace( + '#include ', + `#include + vWorldPos = position; // (modelMatrix * vec4(position, 1.0)).xyz;` + ); + + // Displace in fragment and recompute normals from that + shader.fragmentShader = shader.fragmentShader + .replace( + '#include ', + `#include + uniform sampler2D displacementMap; + uniform float displacementScale; + uniform mat3 normalMatrix; + varying vec2 vUv; + varying vec3 vWorldPos;` + ) + .replace( + '#include ', + ` + float d = texture2D(displacementMap, vUv).r- 0.5; + vec3 displacedWorld = vWorldPos + vec3(0.0, d * displacementScale, 0.0); + + vec3 dx = dFdx(displacedWorld); + vec3 dy = dFdy(displacedWorld); + vec3 displacedNormal = normalize(cross(dx, dy)); + + vec3 normalView = normalize(normalMatrix * displacedNormal); + vec3 normal = normalView; + vec3 nonPerturbedNormal = normalView; + ` + ); + + }; + + } + +} + diff --git a/examples/jsm/shaders/FluidSimulationShaders.js b/examples/jsm/shaders/FluidSimulationShaders.js new file mode 100644 index 00000000000000..7e54889ccc47af --- /dev/null +++ b/examples/jsm/shaders/FluidSimulationShaders.js @@ -0,0 +1,409 @@ +import { Color } from 'three'; + +// Based on (c) 2017 Pavel Dobryakov : WebGL shader code (https://github.com/PavelDoGreat/WebGL-Fluid-Simulation/tree/master) + +/** + * R - Pressure + * G - X dir + * B - Y dir + * A - wildcard, used to pass values from shader to shader. Not persisted. + */ + +const vertexShader = ` + varying vec2 vUv; + varying vec2 vL; + varying vec2 vR; + varying vec2 vT; + varying vec2 vB; + uniform vec2 texelSize; + + void main() { + vUv = uv; + + vL = uv - vec2(texelSize.x, 0.0); + vR = uv + vec2(texelSize.x, 0.0); + vT = uv + vec2(0.0, texelSize.y); + vB = uv - vec2(0.0, texelSize.y); + + gl_Position = vec4(position, 1.0); + } + `; + +/** + * Introduces either velocity or color into target. Depending on `splatVelocity` flag. + */ +export const SplatShader = { + uniforms: { + uTarget: { value: null }, + splatVelocity: { value: false }, + color: { value: new Color( 0xffffff ) }, + texelSize: { value: null }, + objectData: { value: null }, // Contains current and previous object positions + count: { value: 1 }, + thickness: { value: 0.035223 }, // in UV units + aspectRatio: { value: 1 }, // in UV units + splatForce: { value: - 196 } + }, + + vertexShader, + fragmentShader: ` + precision mediump float; + precision mediump sampler2D; + + varying highp vec2 vUv; + uniform sampler2D uTarget; + uniform sampler2D objectData; + uniform int count; + uniform float thickness; //TODO: this shold be individual per object to allow diferent types of bodies affecting the liquid + uniform float aspectRatio; + uniform highp vec2 texelSize; + uniform bool splatVelocity; + uniform vec3 color; + uniform float splatForce; + + void main () { + + vec4 pixel = texture2D(uTarget, vUv); + + // Add External Forces (from objects) + // IMPROVEMENT: This loop is much more efficient as it reads from a texture. + for (int i = 0; i < count; i++) { + // Read object data from the texture. + // texelFetch is used for direct, un-interpolated pixel reads. + vec4 data = texelFetch(objectData, ivec2(i, 0), 0); + vec2 curr = data.xy; // Current position in .xy + vec2 prev = data.zw; // Previous position in .zw + + vec2 diff = curr - prev; + if (length(diff) == 0.0) continue; // Skip if the object hasn't moved + + vec2 toFrag = vUv - prev; + float t = clamp(dot(toFrag, diff) / dot(diff, diff), 0.0, 1.0); + vec2 proj = prev + t * diff; + + vec2 aspect = vec2(aspectRatio, 1.0); + + // Calculate distance in a way that respects the screen's aspect ratio + float d = distance(vUv * aspect, proj * aspect); + + if (d < thickness) { + // IMPROVEMENT: Correct influence logic. + // Influence is strongest when distance 'd' is 0. + float influence = smoothstep(thickness, 0.0, d); + + if( splatVelocity ) + { + + vec2 vel = normalize( ( diff )/texelSize ) * -splatForce; + + + //vel = mix( pixel.gb, vel, influence ); + + pixel.g = vel.x; + pixel.b = vel.y; + } + else + { + pixel = mix( pixel, vec4( color, 1.0 ), influence ); + } + + } + } + + gl_FragColor = pixel; + } + ` +}; + +/** + * sets vorticity inthe alpha channel of uVelocity image + */ +export const CurlShader = { + uniforms: { + uVelocity: { value: null }, + texelSize: { value: null }, + vorticityInfluence: { value: 1 } + }, + vertexShader, + fragmentShader: ` + precision mediump float; + precision mediump sampler2D; + + varying highp vec2 vUv; + varying highp vec2 vL; + varying highp vec2 vR; + varying highp vec2 vT; + varying highp vec2 vB; + uniform sampler2D uVelocity; + uniform float vorticityInfluence; + + void main () { + float L = texture2D(uVelocity, vL).b; + float R = texture2D(uVelocity, vR).b; + float T = texture2D(uVelocity, vT).g; + float B = texture2D(uVelocity, vB).g; + float vorticity = R - L - T + B; + + vec4 pixel = texture2D(uVelocity, vUv); + + pixel.a = vorticityInfluence * vorticity; // set in the 4th component... + + gl_FragColor = pixel; + } + ` +}; + +/** + * updates the velocity image + */ +export const VorticityShader = { + uniforms: { + uVelocityAndCurl: { value: null }, + texelSize: { value: null }, + curl: { value: 1 }, + dt: { value: 0 }, + }, + vertexShader, + fragmentShader: ` + precision highp float; + precision highp sampler2D; + + varying vec2 vUv; + varying vec2 vL; + varying vec2 vR; + varying vec2 vT; + varying vec2 vB; + uniform sampler2D uVelocityAndCurl; + uniform float curl; + uniform float dt; + + void main () { + float L = texture2D(uVelocityAndCurl, vL).a; + float R = texture2D(uVelocityAndCurl, vR).a; + float T = texture2D(uVelocityAndCurl, vT).a; + float B = texture2D(uVelocityAndCurl, vB).a; + float C = texture2D(uVelocityAndCurl, vUv).a; + + vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L)); + force /= length(force) + 0.0001; + force *= curl * C; + force.y *= -1.0; + + vec4 pixel = texture2D(uVelocityAndCurl, vUv); + + vec2 velocity = pixel.gb; + velocity += force * dt; + velocity = min(max(velocity, -1000.0), 1000.0); + + gl_FragColor = vec4( pixel.r, velocity, 0.0 ); + } + ` +}; + +/** + * Adds divergence in the alpha channel of the velocity image + */ +export const DivergenceShader = { + uniforms: { + uVelocity: { value: null }, + texelSize: { value: null }, + }, + vertexShader, + fragmentShader: ` + precision mediump float; + precision mediump sampler2D; + + varying highp vec2 vUv; + varying highp vec2 vL; + varying highp vec2 vR; + varying highp vec2 vT; + varying highp vec2 vB; + uniform sampler2D uVelocity; + + void main () { + float L = texture2D(uVelocity, vL).g; + float R = texture2D(uVelocity, vR).g; + float T = texture2D(uVelocity, vT).b; + float B = texture2D(uVelocity, vB).b; + + vec4 pixel = texture2D(uVelocity, vUv); + + vec2 C = pixel.gb; + if (vL.x < 0.0) { L = -C.x; } + if (vR.x > 1.0) { R = -C.x; } + if (vT.y > 1.0) { T = -C.y; } + if (vB.y < 0.0) { B = -C.y; } + + float div = 0.5 * (R - L + T - B); + + gl_FragColor = vec4( pixel.r, C, div ); + } + ` +}; + +/** + * Multiplies the pressure by `value` uniform + */ +export const ClearShader = { + uniforms: { + uTexture: { value: null }, + value: { value: 0.317 }, //PRESSURE + texelSize: { value: null }, + }, + vertexShader, + fragmentShader: ` + precision mediump float; + precision mediump sampler2D; + + varying highp vec2 vUv; + uniform sampler2D uTexture; + uniform float value; + + void main () { + vec4 pixel = texture2D(uTexture, vUv); + + pixel.r *= value; + + gl_FragColor = pixel ; + } + ` +}; + +/** + * updates the pressure of the image + */ +export const PressureShader = { + uniforms: { + uPressureWithDivergence: { value: null }, + texelSize: { value: null }, + }, + vertexShader, + fragmentShader: ` + precision mediump float; + precision mediump sampler2D; + + varying highp vec2 vUv; + varying highp vec2 vL; + varying highp vec2 vR; + varying highp vec2 vT; + varying highp vec2 vB; + uniform sampler2D uPressureWithDivergence; + + void main () { + float L = texture2D(uPressureWithDivergence, vL).x; + float R = texture2D(uPressureWithDivergence, vR).x; + float T = texture2D(uPressureWithDivergence, vT).x; + float B = texture2D(uPressureWithDivergence, vB).x; + float C = texture2D(uPressureWithDivergence, vUv).x; + + vec4 pixel = texture2D(uPressureWithDivergence, vUv); + float divergence = pixel.a; + float pressure = (L + R + B + T - divergence) * 0.25; + + pixel.x = pressure; + + gl_FragColor = pixel; + } + ` +}; + + +export const GradientSubtractShader = { + uniforms: { + uPressureWithVelocity: { value: null }, + texelSize: { value: null }, + }, + vertexShader, + fragmentShader: ` + precision mediump float; + precision mediump sampler2D; + + varying highp vec2 vUv; + varying highp vec2 vL; + varying highp vec2 vR; + varying highp vec2 vT; + varying highp vec2 vB; + uniform sampler2D uPressureWithVelocity; + + void main () { + float L = texture2D(uPressureWithVelocity, vL).x; + float R = texture2D(uPressureWithVelocity, vR).x; + float T = texture2D(uPressureWithVelocity, vT).x; + float B = texture2D(uPressureWithVelocity, vB).x; + + vec4 pixel = texture2D(uPressureWithVelocity, vUv); + + vec2 velocity = pixel.gb; + velocity.xy -= vec2(R - L, T - B); + + gl_FragColor = vec4( pixel.r, velocity, 0.0 ); + } + ` +}; + + +export const AdvectVelocityShader = { + uniforms: { + uVelocity: { value: null }, + uSource: { value: null }, + sourceIsVelocity: { value: null }, + texelSize: { value: null }, + dt: { value: 0 }, + dyeTexelSize: { value: null }, + dissipation: { value: 0.2 }, + }, + defines: { + MANUAL_FILTERING: false + }, + vertexShader, + fragmentShader: ` + precision highp float; + precision highp sampler2D; + + varying vec2 vUv; + uniform sampler2D uVelocity; + uniform sampler2D uSource; + uniform vec2 texelSize; + uniform vec2 dyeTexelSize; + uniform float dt; + uniform float dissipation; + uniform bool sourceIsVelocity; + + vec4 bilerp (sampler2D sam, vec2 uv, vec2 tsize) { + vec2 st = uv / tsize - 0.5; + + vec2 iuv = floor(st); + vec2 fuv = fract(st); + + vec4 a = texture2D(sam, (iuv + vec2(0.5, 0.5)) * tsize); + vec4 b = texture2D(sam, (iuv + vec2(1.5, 0.5)) * tsize); + vec4 c = texture2D(sam, (iuv + vec2(0.5, 1.5)) * tsize); + vec4 d = texture2D(sam, (iuv + vec2(1.5, 1.5)) * tsize); + + return mix(mix(a, b, fuv.x), mix(c, d, fuv.x), fuv.y); + } + + void main () { + + #ifdef MANUAL_FILTERING + vec2 coord = vUv - dt * bilerp(uVelocity, vUv, texelSize).gb * texelSize; + vec4 result = bilerp(uSource, coord, dyeTexelSize); + #else + vec2 coord = vUv - dt * texture2D(uVelocity, vUv).gb * texelSize; + vec4 result = texture2D(uSource, coord); + #endif + float decay = 1.0 + dissipation * dt; + result /= decay; + + if( sourceIsVelocity ) + { + vec4 data = texture2D(uVelocity, vUv); + gl_FragColor = vec4( data.r, result.g, result.b, data.a); + } + else + { + gl_FragColor = result; + } + } + ` +}; diff --git a/examples/screenshots/webgl_materials_fluidsim.jpg b/examples/screenshots/webgl_materials_fluidsim.jpg new file mode 100644 index 00000000000000..063e95c50e3974 Binary files /dev/null and b/examples/screenshots/webgl_materials_fluidsim.jpg differ diff --git a/examples/webgl_materials_fluidsim.html b/examples/webgl_materials_fluidsim.html new file mode 100644 index 00000000000000..ad9074dd4d6ecf --- /dev/null +++ b/examples/webgl_materials_fluidsim.html @@ -0,0 +1,166 @@ + + + + three.js webgl - materials + + + + + + +
+
three.js - Fluid Simulation
+ + + + + + +