FDNavigate back to the homepage

Dynamic FlowMap Curve Modifier for Procedural River Generation

Rick
September 10th, 2024 · 1 min read

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])
2
3if (points.length > 2) {
4 const spline = new THREE.CatmullRomCurve3(points);
5 const curvedPoints = spline.getPoints(300);
6
7 const splineEdgeA = new THREE.CatmullRomCurve3(points.map(pt => {
8 const modPt = pt.clone().multiplyScalar(0.8);
9
10 return modPt;
11 }));
12
13 const edgeAPoints = splineEdgeA.getPoints(300);
14
15 setEdgeAPoints(edgeAPoints);
16
17 const splineEdgeB = new THREE.CatmullRomCurve3(points.map(pt => {
18 const modPt = pt.clone().multiplyScalar(1.2);
19
20 return modPt;
21 }));
22
23 const edgeBPoints = splineEdgeB.getPoints(300);
24
25 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);
3
4 return modPt;
5}));

And this:

1const splineEdgeB = new THREE.CatmullRomCurve3(points.map(pt => {
2 const modPt = pt.clone().multiplyScalar(1.2);
3
4 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));
2
3const ptsConcave = modPoints.map((item) => ([item.x, item.y]))
4
5console.log({ptsConcave})
6
7const hullEdges = new hull(ptsConcave, 1);
8
9console.log()
10
11const shape = new THREE.Shape().setFromPoints(hullEdges.map((item) => new THREE.Vector2(item[0], item[1])));
12
13const shapeGeometry = new THREE.ShapeGeometry(shape);
14
15
16let geometry = new THREE.BufferGeometry().setFromPoints( curvedPoints );
17let material = new THREE.LineBasicMaterial({ color: 0xffdd00 });

So how do we do uvs?

consider this:

How to get the direction into the shader

here is the code:

1const dirs = [];
2
3for (let i = 0; i < hullEdges.length; i += 1) {
4 const point = new THREE.Vector3(hullEdges[i][0], 0, hullEdges[i][1]);
5
6 var { closestPoint, closestNum } = findClosestPointOnSpline(spline, point);
7 const directionAlongSplineA = spline.getPointAt(closestNum)
8 const directionAlongSplineB = spline.getPointAt(Math.min(closestNum + 0.001, 1.0))
9
10 const dir = directionAlongSplineB.sub(directionAlongSplineA);
11 dirs.push(dir.normalize());
12 // console.log("Closest point on spline:", closestPoint);
13}
14
15let pointAAttribute = new THREE.Float32BufferAttribute(
16 new Float32Array(dirs.map((item) => [item.x, item.y, item.z]).flat()),
17 3
18);
19
20setUV(shapeGeometry);
21
22shapeGeometry.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'
3
4// Calculate bounding box
5const boundingBox = new THREE.Box3().setFromBufferAttribute(geometry.getAttribute('position'));
6
7// Get dimensions of the bounding box
8const size = boundingBox.getSize(new THREE.Vector3());
9
10// Get the minimum corner of the bounding box
11const min = boundingBox.min;
12
13// Calculate UVs based on normalized vertex positions
14const uvs = [];
15
16for (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}
25
26// Set UVs as an attribute of the BufferGeometry
27geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(uvs), 2));
28
29}

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 <shaderMaterial
3 ref={riverRef}
4 attach="material"
5 args={[
6 {
7 uniforms,
8 vertexShader,
9 fragmentShader
10 }
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;
5
6
7 void main () {
8 vUv = uv;
9 vDirection = direction;
10 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
11 }
12`;
13
14const fragmentShader = `
15 // https://codesandbox.io/p/sandbox/dazzling-sid-mwb1zw?file=%2Fsrc%2Friver.js%3A73%2C26&from-embed=
16
17 uniform sampler2D flowTexture;
18 uniform sampler2D riverTexture;
19 varying vec2 vUv;
20 uniform float flowSpeed;
21 uniform float cycleTime;
22 uniform float time;
23
24 varying vec3 vDirection;
25
26 void main () {
27 vec2 flowDirection = normalize(vDirection.xz);
28
29 // Use two cycles, offset by a half so we can blend between them
30 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 );
40
41 // FLOW MAP
42 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/YzOjyRN
2
3import * 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";
8
9import waterImg from './water.jpg';
10
11const vertexShader = `
12 attribute vec3 direction;
13 varying vec2 vUv;
14 varying vec3 vDirection;
15
16
17 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=
25
26 uniform sampler2D flowTexture;
27 uniform sampler2D riverTexture;
28 varying vec2 vUv;
29 uniform float flowSpeed;
30 uniform float cycleTime;
31 uniform float time;
32
33 varying vec3 vDirection;
34 varying vec3 pntA;
35 varying vec3 pntB;
36
37 void main () {
38 vec2 flowDirection = normalize(vDirection.xz);
39
40 // Use two cycles, offset by a half so we can blend between them
41 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 );
51
52 // FLOW MAP
53 gl_FragColor = mix( color1, color2, abs(cycleTime1 - 0.5) * 2.0 );
54 }
55`;
56
57function Spline() {
58 const curve = useRef(new THREE.SplineCurve());
59 const riverRef = useRef();
60 const { camera, scene, gl } = useThree();
61
62 const [points, setPoints] = useState([]);
63 const [edgeAPoints, setEdgeAPoints] = useState([])
64 const [edgeBPoints, setEdgeBPoints] = useState([])
65
66 const [shapeGeometry, setShapeGeometry] = useState(new THREE.ShapeGeometry());
67
68 const [curveObject, setCurveObject] = useState([]);
69 const [objects, setObjects] = useState([]);
70
71 const setUV = (geometry) => {
72 // Assuming you have a BufferGeometry named 'geometry'
73
74 // Calculate bounding box
75 const boundingBox = new THREE.Box3().setFromBufferAttribute(geometry.getAttribute('position'));
76
77 // Get dimensions of the bounding box
78 const size = boundingBox.getSize(new THREE.Vector3());
79
80 // Get the minimum corner of the bounding box
81 const min = boundingBox.min;
82
83 // Calculate UVs based on normalized vertex positions
84 const uvs = [];
85
86 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 }
95
96 // Set UVs as an attribute of the BufferGeometry
97 geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(uvs), 2));
98
99 }
100
101 // Function to find the closest point on the spline to a given vec3
102 function findClosestPointOnSpline(spline, point) {
103 let closestPoint = null;
104 let closestNum = 0;
105 let closestDistanceSq = Number.POSITIVE_INFINITY;
106
107 // Sample points along the curve
108 let divisions = 1000; // Adjust this according to the required precision
109 let points = spline.getPoints(divisions);
110
111 // Iterate through sampled points
112 for (let i = 0; i < points.length; i++) {
113
114 let currentPoint = points[i];
115 let distanceSq = currentPoint.distanceToSquared(point);
116
117 // Check if this point is closer than the previous closest point
118 if (distanceSq < closestDistanceSq) {
119 closestNum = i;
120 closestDistanceSq = distanceSq;
121 closestPoint = currentPoint.clone();
122 }
123 }
124
125 return { closestPoint, closestNum: closestNum / 1000 };
126 }
127
128 const onClick = ({point}) => {
129 setPoints(old => [...old, point])
130
131 if (points.length > 2) {
132 const spline = new THREE.CatmullRomCurve3(points);
133 const curvedPoints = spline.getPoints(300);
134
135 const splineEdgeA = new THREE.CatmullRomCurve3(points.map(pt => {
136 const modPt = pt.clone().multiplyScalar(0.8);
137
138 return modPt;
139 }));
140
141 const edgeAPoints = splineEdgeA.getPoints(300);
142
143 setEdgeAPoints(edgeAPoints);
144
145 const splineEdgeB = new THREE.CatmullRomCurve3(points.map(pt => {
146 const modPt = pt.clone().multiplyScalar(1.2);
147
148 return modPt;
149 }));
150
151 const edgeBPoints = splineEdgeB.getPoints(300);
152
153 setEdgeBPoints(edgeBPoints);
154
155 const modPoints = [...edgeAPoints, ...edgeBPoints].map((vec3) => new THREE.Vector2(vec3.x, vec3.z));
156
157 const ptsConcave = modPoints.map((item) => ([item.x, item.y]))
158
159 console.log({ptsConcave})
160
161 const hullEdges = new hull(ptsConcave, 1);
162
163 console.log()
164
165 const shape = new THREE.Shape().setFromPoints(hullEdges.map((item) => new THREE.Vector2(item[0], item[1])));
166
167 const shapeGeometry = new THREE.ShapeGeometry(shape);
168
169
170 let geometry = new THREE.BufferGeometry().setFromPoints( curvedPoints );
171 let material = new THREE.LineBasicMaterial({ color: 0xffdd00 });
172
173 const dirs = [];
174
175 for (let i = 0; i < hullEdges.length; i += 1) {
176 const point = new THREE.Vector3(hullEdges[i][0], 0, hullEdges[i][1]);
177
178 var { closestPoint, closestNum } = findClosestPointOnSpline(spline, point);
179 const directionAlongSplineA = spline.getPointAt(closestNum)
180 const directionAlongSplineB = spline.getPointAt(Math.min(closestNum + 0.001, 1.0))
181
182 const dir = directionAlongSplineB.sub(directionAlongSplineA);
183 dirs.push(dir.normalize());
184 // console.log("Closest point on spline:", closestPoint);
185 }
186
187 let pointAAttribute = new THREE.Float32BufferAttribute(
188 new Float32Array(dirs.map((item) => [item.x, item.y, item.z]).flat()),
189 3
190 );
191
192 setUV(shapeGeometry);
193
194 shapeGeometry.setAttribute('direction', pointAAttribute);
195
196 shapeGeometry.attributes.direction.needsUpdate = true;
197 setShapeGeometry(shapeGeometry);
198
199 setObjects([geometry, material])
200 }
201 }
202
203 useEffect(() => {
204 if (shapeGeometry.attributes.pointA) {
205 shapeGeometry.attributes.pointA.needsUpdate = true;
206 }
207
208 if (shapeGeometry.attributes.pointB) {
209 shapeGeometry.attributes.pointB.needsUpdate = true;
210 }
211
212 if (shapeGeometry.attributes.uv) {
213 shapeGeometry.attributes.uv.needsUpdate = true;
214 }
215
216 }, [shapeGeometry])
217
218 const [riverTexture] = useLoader(THREE.TextureLoader, [waterImg]);
219
220 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 });
229
230 const uniforms = {
231 flowTexture: { value: null },
232 riverTexture: { value: null },
233 flowSpeed: { value: null },
234 cycleTime: { value: null },
235 time: { value: 0 }
236 };
237
238
239 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 })}
249
250 {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 })}
258
259 {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 })}
267
268 <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>
272
273 <line geometry={objects[0]} material={objects[1]} />
274
275 <mesh geometry={shapeGeometry} rotation={[Math.PI/2, 0, 0]} position-y={0.2}>
276 <shaderMaterial
277 ref={riverRef}
278 attach="material"
279 args={[
280 {
281 uniforms,
282 vertexShader,
283 fragmentShader
284 }
285 ]}
286 side={THREE.DoubleSide}
287 />
288 </mesh>
289 </>
290 );
291}
292
293export default function FlowMap() {
294 return (
295 <Canvas
296 camera={{
297 position: [0, 0, 1],
298 near: 0.01,
299 far: 100
300 }}
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}

More articles from theFrontDev

Centroids - Precursor to Normal Calculation in Point Clouds

This project involves dividing a bounding box into equal sections to compute and visualize 3D centroids of points within each section. It serves as a precursor to calculating local normals for point clouds. The visualization includes section boundaries, centroid spheres, and normal vectors to aid in understanding point distributions and orientations.

September 7th, 2024 · 1 min read

Crafting a Spatially Expanding Blob in Three.js - Dynamic Geometry and Bounding Box Alignment

This article explores the creation of a spatially expanding blob in Three.js, where dynamic geometry is used to gradually fill and align with an outer bounding box. Learn how to manipulate vertices and apply spatial transformations, ensuring the blob seamlessly expands and sits flush within its container, creating visually engaging 3D effects.

September 3rd, 2024 · 1 min read
© 2021–2024 theFrontDev
Link to $https://twitter.com/TheFrontDevLink to $https://github.com/Richard-ThompsonLink to $https://www.linkedin.com/in/richard-thompson-248ba3111/