This project was research into how to create a simple curve modifier for a river with a dynamic flow map.
This is R&D and is NOT a complete task.
The UVs are not perfect and could be a take away for you to fix or play with.
The premise is that we add points to state and create a spline from these points.
Take below as a starting point.
1setPoints(old => [...old, point])23if (points.length > 2) {4 const spline = new THREE.CatmullRomCurve3(points);5 const curvedPoints = spline.getPoints(300);67 const splineEdgeA = new THREE.CatmullRomCurve3(points.map(pt => {8 const modPt = pt.clone().multiplyScalar(0.8);910 return modPt;11 }));1213 const edgeAPoints = splineEdgeA.getPoints(300);1415 setEdgeAPoints(edgeAPoints);1617 const splineEdgeB = new THREE.CatmullRomCurve3(points.map(pt => {18 const modPt = pt.clone().multiplyScalar(1.2);1920 return modPt;21 }));2223 const edgeBPoints = splineEdgeB.getPoints(300);2425 setEdgeBPoints(edgeBPoints);
What we do is get the initial spline and then go left or right perpendicular to the original point.
For example this:
1const splineEdgeA = new THREE.CatmullRomCurve3(points.map(pt => {2 const modPt = pt.clone().multiplyScalar(0.8);34 return modPt;5}));
And this:
1const splineEdgeB = new THREE.CatmullRomCurve3(points.map(pt => {2 const modPt = pt.clone().multiplyScalar(1.2);34 return modPt;5}));
now we use a concave algorithm to use the 2 perimeter points of the original spline.
1const modPoints = [...edgeAPoints, ...edgeBPoints].map((vec3) => new THREE.Vector2(vec3.x, vec3.z));23const ptsConcave = modPoints.map((item) => ([item.x, item.y]))45console.log({ptsConcave})67const hullEdges = new hull(ptsConcave, 1);89console.log()1011const shape = new THREE.Shape().setFromPoints(hullEdges.map((item) => new THREE.Vector2(item[0], item[1])));1213const shapeGeometry = new THREE.ShapeGeometry(shape);141516let geometry = new THREE.BufferGeometry().setFromPoints( curvedPoints );17let material = new THREE.LineBasicMaterial({ color: 0xffdd00 });
So how do we do uvs?
consider this:
here is the code:
1const dirs = [];23for (let i = 0; i < hullEdges.length; i += 1) {4 const point = new THREE.Vector3(hullEdges[i][0], 0, hullEdges[i][1]);56 var { closestPoint, closestNum } = findClosestPointOnSpline(spline, point);7 const directionAlongSplineA = spline.getPointAt(closestNum)8 const directionAlongSplineB = spline.getPointAt(Math.min(closestNum + 0.001, 1.0))910 const dir = directionAlongSplineB.sub(directionAlongSplineA);11 dirs.push(dir.normalize());12 // console.log("Closest point on spline:", closestPoint);13}1415let pointAAttribute = new THREE.Float32BufferAttribute(16 new Float32Array(dirs.map((item) => [item.x, item.y, item.z]).flat()),17 318);1920setUV(shapeGeometry);2122shapeGeometry.setAttribute('direction', pointAAttribute);
Essentially calculating the directions and then setting it in an attribute.
As we are creating a new shape we need to re calculate the UVs as shown below:
1const setUV = (geometry) => {2// Assuming you have a BufferGeometry named 'geometry'34// Calculate bounding box5const boundingBox = new THREE.Box3().setFromBufferAttribute(geometry.getAttribute('position'));67// Get dimensions of the bounding box8const size = boundingBox.getSize(new THREE.Vector3());910// Get the minimum corner of the bounding box11const min = boundingBox.min;1213// Calculate UVs based on normalized vertex positions14const uvs = [];1516for (let i = 0; i < geometry.attributes.position.array.length; i += 3) {17 const val = new THREE.Vector3(18 geometry.attributes.position.array[i],19 geometry.attributes.position.array[i + 1],20 geometry.attributes.position.array[i + 2]21 );22 uvs.push((val.x - min.x) / size.x); // Map x coordinates to range [0, 1]23 uvs.push((val.y - min.y) / size.y); // Map y coordinates to range [0, 1]24}2526// Set UVs as an attribute of the BufferGeometry27geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(uvs), 2));2829}
The uvs are calculated by removing the min of the bounding box and then dividing by the total size to get the coordinates into the range 0-1, which is the uv range.
This then becomes where we put the concave hull mesh which generates the surface for the flow map.
1<mesh geometry={shapeGeometry} rotation={[Math.PI/2, 0, 0]} position-y={0.2}>2 <shaderMaterial3 ref={riverRef}4 attach="material"5 args={[6 {7 uniforms,8 vertexShader,9 fragmentShader10 }11 ]}12 side={THREE.DoubleSide}13 />14</mesh>
The flow map shaders are as below and you can get a general idea from this article.
1const vertexShader = `2 attribute vec3 direction;3 varying vec2 vUv;4 varying vec3 vDirection;567 void main () {8 vUv = uv;9 vDirection = direction;10 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);11 }12`;1314const fragmentShader = `15 // https://codesandbox.io/p/sandbox/dazzling-sid-mwb1zw?file=%2Fsrc%2Friver.js%3A73%2C26&from-embed=1617 uniform sampler2D flowTexture;18 uniform sampler2D riverTexture;19 varying vec2 vUv;20 uniform float flowSpeed;21 uniform float cycleTime;22 uniform float time;2324 varying vec3 vDirection;2526 void main () {27 vec2 flowDirection = normalize(vDirection.xz);2829 // Use two cycles, offset by a half so we can blend between them30 float t1 = time / cycleTime;31 float t2 = t1 + 0.5;32 float cycleTime1 = t1 - floor(t1);33 float cycleTime2 = t2 - floor(t2);34 vec2 flowDirection1 = flowDirection * cycleTime1 * flowSpeed;35 vec2 flowDirection2 = flowDirection * cycleTime2 * flowSpeed;36 vec2 uv1 = vUv + flowDirection1;37 vec2 uv2 = vUv + flowDirection2;38 vec4 color1 = texture2D( riverTexture, uv1 );39 vec4 color2 = texture2D( riverTexture, uv2 );4041 // FLOW MAP42 gl_FragColor = mix( color1, color2, abs(cycleTime1 - 0.5) * 2.0 );43 }44`;
vDirection is passed from the vertex shader to the fragment shader and goes through interpolation.
We then use this direction in the fragment shader where the flow map is constructed. This is not quite right and needs tweaking abit but you get the general idea.
Heres the full example:
1// https://codepen.io/boytchev/pen/YzOjyRN23import * as THREE from "three";4import React, { useRef, useState, useEffect } from "react";5import { Canvas, useFrame, useThree, useLoader } from "@react-three/fiber";6import { Grid, OrbitControls, Points } from '@react-three/drei';7import hull from "hull.js";89import waterImg from './water.jpg';1011const vertexShader = `12 attribute vec3 direction;13 varying vec2 vUv;14 varying vec3 vDirection;151617 void main () {18 vUv = uv;19 vDirection = direction;20 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);21 }22`;23const fragmentShader = `24 // https://codesandbox.io/p/sandbox/dazzling-sid-mwb1zw?file=%2Fsrc%2Friver.js%3A73%2C26&from-embed=2526 uniform sampler2D flowTexture;27 uniform sampler2D riverTexture;28 varying vec2 vUv;29 uniform float flowSpeed;30 uniform float cycleTime;31 uniform float time;3233 varying vec3 vDirection;34 varying vec3 pntA;35 varying vec3 pntB;3637 void main () {38 vec2 flowDirection = normalize(vDirection.xz);3940 // Use two cycles, offset by a half so we can blend between them41 float t1 = time / cycleTime;42 float t2 = t1 + 0.5;43 float cycleTime1 = t1 - floor(t1);44 float cycleTime2 = t2 - floor(t2);45 vec2 flowDirection1 = flowDirection * cycleTime1 * flowSpeed;46 vec2 flowDirection2 = flowDirection * cycleTime2 * flowSpeed;47 vec2 uv1 = vUv + flowDirection1;48 vec2 uv2 = vUv + flowDirection2;49 vec4 color1 = texture2D( riverTexture, uv1 );50 vec4 color2 = texture2D( riverTexture, uv2 );5152 // FLOW MAP53 gl_FragColor = mix( color1, color2, abs(cycleTime1 - 0.5) * 2.0 );54 }55`;5657function Spline() {58 const curve = useRef(new THREE.SplineCurve());59 const riverRef = useRef();60 const { camera, scene, gl } = useThree();6162 const [points, setPoints] = useState([]);63 const [edgeAPoints, setEdgeAPoints] = useState([])64 const [edgeBPoints, setEdgeBPoints] = useState([])6566 const [shapeGeometry, setShapeGeometry] = useState(new THREE.ShapeGeometry());6768 const [curveObject, setCurveObject] = useState([]);69 const [objects, setObjects] = useState([]);7071 const setUV = (geometry) => {72 // Assuming you have a BufferGeometry named 'geometry'7374 // Calculate bounding box75 const boundingBox = new THREE.Box3().setFromBufferAttribute(geometry.getAttribute('position'));7677 // Get dimensions of the bounding box78 const size = boundingBox.getSize(new THREE.Vector3());7980 // Get the minimum corner of the bounding box81 const min = boundingBox.min;8283 // Calculate UVs based on normalized vertex positions84 const uvs = [];8586 for (let i = 0; i < geometry.attributes.position.array.length; i += 3) {87 const val = new THREE.Vector3(88 geometry.attributes.position.array[i],89 geometry.attributes.position.array[i + 1],90 geometry.attributes.position.array[i + 2]91 );92 uvs.push((val.x - min.x) / size.x); // Map x coordinates to range [0, 1]93 uvs.push((val.y - min.y) / size.y); // Map y coordinates to range [0, 1]94 }9596 // Set UVs as an attribute of the BufferGeometry97 geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(uvs), 2));9899 }100101 // Function to find the closest point on the spline to a given vec3102 function findClosestPointOnSpline(spline, point) {103 let closestPoint = null;104 let closestNum = 0;105 let closestDistanceSq = Number.POSITIVE_INFINITY;106107 // Sample points along the curve108 let divisions = 1000; // Adjust this according to the required precision109 let points = spline.getPoints(divisions);110111 // Iterate through sampled points112 for (let i = 0; i < points.length; i++) {113114 let currentPoint = points[i];115 let distanceSq = currentPoint.distanceToSquared(point);116117 // Check if this point is closer than the previous closest point118 if (distanceSq < closestDistanceSq) {119 closestNum = i;120 closestDistanceSq = distanceSq;121 closestPoint = currentPoint.clone();122 }123 }124125 return { closestPoint, closestNum: closestNum / 1000 };126 }127128 const onClick = ({point}) => {129 setPoints(old => [...old, point])130131 if (points.length > 2) {132 const spline = new THREE.CatmullRomCurve3(points);133 const curvedPoints = spline.getPoints(300);134135 const splineEdgeA = new THREE.CatmullRomCurve3(points.map(pt => {136 const modPt = pt.clone().multiplyScalar(0.8);137138 return modPt;139 }));140141 const edgeAPoints = splineEdgeA.getPoints(300);142143 setEdgeAPoints(edgeAPoints);144145 const splineEdgeB = new THREE.CatmullRomCurve3(points.map(pt => {146 const modPt = pt.clone().multiplyScalar(1.2);147148 return modPt;149 }));150151 const edgeBPoints = splineEdgeB.getPoints(300);152153 setEdgeBPoints(edgeBPoints);154155 const modPoints = [...edgeAPoints, ...edgeBPoints].map((vec3) => new THREE.Vector2(vec3.x, vec3.z));156157 const ptsConcave = modPoints.map((item) => ([item.x, item.y]))158159 console.log({ptsConcave})160161 const hullEdges = new hull(ptsConcave, 1);162163 console.log()164165 const shape = new THREE.Shape().setFromPoints(hullEdges.map((item) => new THREE.Vector2(item[0], item[1])));166167 const shapeGeometry = new THREE.ShapeGeometry(shape);168169170 let geometry = new THREE.BufferGeometry().setFromPoints( curvedPoints );171 let material = new THREE.LineBasicMaterial({ color: 0xffdd00 });172173 const dirs = [];174175 for (let i = 0; i < hullEdges.length; i += 1) {176 const point = new THREE.Vector3(hullEdges[i][0], 0, hullEdges[i][1]);177178 var { closestPoint, closestNum } = findClosestPointOnSpline(spline, point);179 const directionAlongSplineA = spline.getPointAt(closestNum)180 const directionAlongSplineB = spline.getPointAt(Math.min(closestNum + 0.001, 1.0))181182 const dir = directionAlongSplineB.sub(directionAlongSplineA);183 dirs.push(dir.normalize());184 // console.log("Closest point on spline:", closestPoint);185 }186187 let pointAAttribute = new THREE.Float32BufferAttribute(188 new Float32Array(dirs.map((item) => [item.x, item.y, item.z]).flat()),189 3190 );191192 setUV(shapeGeometry);193194 shapeGeometry.setAttribute('direction', pointAAttribute);195196 shapeGeometry.attributes.direction.needsUpdate = true;197 setShapeGeometry(shapeGeometry);198199 setObjects([geometry, material])200 }201 }202203 useEffect(() => {204 if (shapeGeometry.attributes.pointA) {205 shapeGeometry.attributes.pointA.needsUpdate = true;206 }207208 if (shapeGeometry.attributes.pointB) {209 shapeGeometry.attributes.pointB.needsUpdate = true;210 }211212 if (shapeGeometry.attributes.uv) {213 shapeGeometry.attributes.uv.needsUpdate = true;214 }215216 }, [shapeGeometry])217218 const [riverTexture] = useLoader(THREE.TextureLoader, [waterImg]);219220 useFrame(({ clock }) => {221 if (riverRef.current) {222 riverTexture.wrapS = riverTexture.wrapT = THREE.RepeatWrapping;223 riverRef.current.uniforms.riverTexture.value = riverTexture;224 riverRef.current.uniforms.flowSpeed.value = 0.5;225 riverRef.current.uniforms.cycleTime.value = 20.0;226 riverRef.current.uniforms.time.value = clock.getElapsedTime();227 }228 });229230 const uniforms = {231 flowTexture: { value: null },232 riverTexture: { value: null },233 flowSpeed: { value: null },234 cycleTime: { value: null },235 time: { value: 0 }236 };237238239 return (240 <>241 {points.map((point) => {242 return (243 <mesh position={[point.x, point.y, point.z + 0.1]}>244 <sphereGeometry args={[0.2, 20, 20]} />245 <meshBasicMaterial color={'red'} side={THREE.DoubleSide}/>246 </mesh>247 )248 })}249250 {edgeAPoints.map((point) => {251 return (252 <mesh position={[point.x, point.y, point.z + 0.1]}>253 <sphereGeometry args={[0.2, 20, 20]} />254 <meshBasicMaterial color={'blue'} side={THREE.DoubleSide}/>255 </mesh>256 )257 })}258259 {edgeBPoints.map((point) => {260 return (261 <mesh position={[point.x, point.y, point.z + 0.1]}>262 <sphereGeometry args={[0.2, 20, 20]} />263 <meshBasicMaterial color={'blue'} side={THREE.DoubleSide}/>264 </mesh>265 )266 })}267268 <mesh onClick={onClick} position-z={-0.1} rotation={[Math.PI/2, 0, 0]}>269 <planeGeometry args={[10,10]} />270 <meshStandardMaterial args={['blue']} side={THREE.DoubleSide} />271 </mesh>272273 <line geometry={objects[0]} material={objects[1]} />274275 <mesh geometry={shapeGeometry} rotation={[Math.PI/2, 0, 0]} position-y={0.2}>276 <shaderMaterial277 ref={riverRef}278 attach="material"279 args={[280 {281 uniforms,282 vertexShader,283 fragmentShader284 }285 ]}286 side={THREE.DoubleSide}287 />288 </mesh>289 </>290 );291}292293export default function FlowMap() {294 return (295 <Canvas296 camera={{297 position: [0, 0, 1],298 near: 0.01,299 far: 100300 }}301 onCreated={({ gl }) => {302 gl.setClearColor(new THREE.Color("gainsboro"));303 }}304 >305 <Spline />306 <OrbitControls />307 <Grid args={[10, 10]}/>308 </Canvas>309 );310}