The idea is simple.. we generate a expanding greyscale image on canvas, this can then be used as a displacement map which in defined as a grey scale image!
Instead of having a positive displacement if we tweak it we can get negative displacement, which when played around with - can give very nice cracked look.
Something which seems like it shouldnt be possible live on the web is very accessible to devs wanting to make their scenes more interactive.
All the code is below, go have a look and get some inspiration
General Overview
So the idea is that over time we expand a crack or some kind of Lshape system. We do this on a standard none threejs webgl canvas element, then position it off screen absolutely.
Why and how can this be used?
Well think of it like this we need two things:
- Timings to be accurate and correct
- A way to transfer this canvas texture to our ground mesh
And both of these are simple when you think about it, time is constant in both worlds and there is a thing called a CanvasTexture in Threejs which lets us consume a canvas, and we can do this in realtime. Providing the real time vfx effect.
Canvas Texture and generating the cracks
I got the idea from this codepen and then made a prototype.
And then played around to get a prototype of the cracks/lightning on a jsfiddle before trying to integrate into R3F on a codesandbox.
Take note.. the logic for the animated 2D canvas texture is in, createCracks.js
:
1import * as THREE from "three";23export const render = (4 lightning,5 canvas,6 ctx,7 vfxPlaneRef,8 center,9 size,10 currentUVs11) => {12 let i = 0;1314 var brightness = 0.8;1516 var counter = setInterval(function () {17 console.log({ i });18 const gradient = lightning[i].y / window.innerHeight;1920 const grayValue = Math.round(255 * gradient);2122 const fillColor = `rgb(${grayValue * brightness}, ${23 grayValue * brightness24 }, ${grayValue * brightness})`;2526 ctx.beginPath();27 ctx.fillStyle = fillColor;28 // /* ctx.arc(lightning[i].x - 2, lightning[i].y - 2, Math.min(Math.max(i, 30.0), 40.0 ) / 5.0, 0, 2 * Math.PI, false);*/29 ctx.rect(lightning[i].x, lightning[i].y, i / 5.0, i / 5.0);30 ctx.closePath();31 ctx.stroke();32 ctx.fill();3334 if (vfxPlaneRef.current) {35 const canvasTexture = new THREE.CanvasTexture(canvas);36 canvasTexture.center = new THREE.Vector2(0.5, 0.5);37 if (currentUVs.x < 0.3) {38 canvasTexture.rotation = Math.PI / 3;39 } else if (currentUVs.x > 0.3 && currentUVs.x < 0.7) {40 canvasTexture.rotation = 0;41 } else {42 canvasTexture.rotation = -Math.PI / 3;43 }4445 if (canvasTexture) {46 vfxPlaneRef.current.material.displacementMap = canvasTexture;47 // vfxPlaneRef.current.material.map = canvasTexture;48 }49 }50 if (i > lightning.length / 2.0) {51 clearInterval(counter);52 }53 i += 1;54 }, 20);5556 /* requestAnimationFrame(render) */57};5859export const createLightning = (center, size) => {60 var minSegmentHeight = 16.0;61 var groundHeight = window.innerHeight + 290;62 var roughness = 1.91;63 var maxDifference = window.innerHeight / 5;64 var segmentHeight = groundHeight - center.height;65 var lightning = [];66 console.log({ size });67 for (let i = 0; i < 1; i++) {68 lightning.push({ x: center.x, y: center.y });69 lightning.push({70 x: Math.random() * (window.innerWidth - 100) + 50,71 y: groundHeight + (Math.random() - 0.9) * 5072 });73 }7475 var currDiff = maxDifference;76 while (segmentHeight > minSegmentHeight) {77 var newSegments = [];78 for (var i = 0; i < lightning.length - 1; i++) {79 var start = lightning[i];80 var end = lightning[i + 1];81 var midX = (start.x + end.x) / 2;82 var newX = midX + (Math.random() * 2 - 1) * currDiff;83 newSegments.push(start, { x: newX, y: (start.y + end.y) / 2 });84 }8586 newSegments.push(lightning.pop());87 lightning = newSegments;8889 currDiff /= roughness;90 segmentHeight /= 2;91 }92 return lightning;93};
Im not going to go through and explain everything, although the concepts require a pretty fluent understanding of JS. There are 2 functions one which creates data for the cracks and then one which renders them over time to the canvas texture - the premise is as follows:
- A displacement map, by nature, is a black/white and greyscale image/texture. So this is only going to work if we stick to rgba values which remain consistent across color channels i.e.
1// r/g/b2const color = new Color(0.3, 0.3, 0.3);3```glsl4// r/g/b (or remember.. x/y/z);5vec3 color = vec3(0.3, 0.3, 0.3);
So as long as we have a consistent grey scale then we can create the displacement map. And we can apply this to a MeshStandardMaterial or ShaderMaterial manually in the vertex shader. The general steps in this
- We determine where to start on the canvas texture
- Create a gradient of white as you go further down the map
- Randomly move along the map joining these points
- Creating a cracked/lightning shape
The main setup
Below is all the code for the main file:
1// https://codesandbox.io/s/react-postprocessing-dof-blob-pqrpl?file=/src/App.js2// https://codesandbox.io/s/the-three-graces-0n9it?file=/src/App.js:1531-16203import "./styles.css";4import React, { useEffect, useCallback, useRef } from "react";5import { Canvas, useFrame, useLoader, useThree } from "@react-three/fiber";6import {7 OrbitControls,8 Environment,9 Lightformer,10 Stats11} from "@react-three/drei";12import {13 EffectComposer,14 DepthOfField,15 Bloom,16 Noise,17 Vignette18} from "@react-three/postprocessing";19import * as THREE from "three";20import { easing } from "maath";21import { render, createLightning } from "./createCracks";22import { CanvasTexture } from "three";2324const Plane = () => {25 const { size } = useThree();26 const SIZE_OF_VFX_PLANE_DIMENSIONS = 100;27 const emptyLookAtRef = useRef(null);28 const vfxPlaneRef = useRef(null);29 const canvas = useRef(document.getElementById("canvas"));30 const defaultCanvasTex = useRef(31 new THREE.CanvasTexture(document.getElementById("canvas"))32 );33 const xzMoveToVFXOrigin = useRef({ x: 0.0, z: 0.0 });34 const xzUVSMoveToVFXOrigin = useRef({ x: 0.0, z: 0.0 });35 const tempUvVec2 = new THREE.Vector2();36 let vfxPlaneClicked = useRef(false);3738 const { camera } = useThree();3940 const roughnessMap = useLoader(41 THREE.TextureLoader,42 "https://pub-8f66c3b1ef444eb3bd205620d22e9ccd.r2.dev/worley-noise.jpg"43 );44 roughnessMap.wrapS = roughnessMap.wrapT = THREE.RepeatWrapping;4546 useFrame(({ delta, pointer }) => {47 // console.log({ pointer });48 if (vfxPlaneRef.current) {49 const shader = vfxPlaneRef.current.userData.shader;5051 if (shader) {52 tempUvVec2.x = xzUVSMoveToVFXOrigin.current.x;53 tempUvVec2.y = xzUVSMoveToVFXOrigin.current.y;54 shader.uniforms.vfxOriginUVs = {55 value: tempUvVec256 };57 }58 }59 easing.damp3(60 emptyLookAtRef.current.position,61 [xzMoveToVFXOrigin.current.x, 1.0, xzMoveToVFXOrigin.current.z],62 0.2,63 delta64 );65 });6667 const fireVFX = (point) => {68 var color = "rgb(0,0,0)";6970 var c = document.getElementById("canvas");71 c.width = size.width;72 c.height = size.height;73 var ctx = c.getContext("2d");7475 ctx.filter = "blur(4px)";76 ctx.globalCompositeOperation = "lighter";77 ctx.filter = "blur(4px)";78 ctx.globalCompositeOperation = "lighter";7980 ctx.strokeStyle = color;81 ctx.shadowColor = color;8283 ctx.fillStyle = color;84 ctx.fillRect(0, 0, size.width, size.height);85 ctx.fillStyle = "hsla(0, 0%, 0%, 1.0)";8687 const center = new THREE.Vector2(size.width / 2, 30);8889 for (let i = 0; i < 4; i++) {90 const lightning = createLightning(center, size, canvas.current);9192 console.log({ lightning });9394 if (canvas) {95 render(96 lightning,97 canvas.current,98 ctx,99 vfxPlaneRef,100 point,101 size,102 xzUVSMoveToVFXOrigin.current103 );104 }105106 vfxPlaneClicked.current = false;107 }108 };109110 const OBC = (shader) => {111 // shaders...112 };113114 return (115 <group>116 <mesh dispose={null} ref={emptyLookAtRef} position={[0, 1.0, 0.0]}>117 <sphereGeometry />118 <meshPhysicalMaterial119 roughness={0.0}120 reflectivity={0.0}121 color="black"122 roughnessMap={roughnessMap}123 />124 </mesh>125 <mesh126 dispose={null}127 ref={vfxPlaneRef}128 rotation={[-Math.PI / 2, 0, Math.PI / 2]}129 position={[0, 0.0, 1.0]}130 onPointerMove={({ point, uv }) => {131 if (emptyLookAtRef.current) {132 if (133 point.x > -50.0 &&134 point.x < 10.0 &&135 point.z < 50.0 &&136 point.z > -50.0137 ) {138 xzMoveToVFXOrigin.current = {139 x: point.x,140 z: point.z141 };142143 // emptyLookAtRef.current.position.x = point.x;144 // emptyLookAtRef.current.position.z = point.z;145 emptyLookAtRef.current.lookAt(new THREE.Vector3(100, -1, 0));146 }147 }148 }}149 onClick={({ uv, intersections }) => {150 const point = intersections[0].point;151 xzUVSMoveToVFXOrigin.current = {152 x: uv.x,153 y: 1.0 - uv.y154 };155 console.log({ vfxPlaneClicked });156 if (!vfxPlaneClicked.current) {157 fireVFX(point);158 }159 vfxPlaneClicked = true;160 }}161 >162 <planeGeometry163 args={[164 SIZE_OF_VFX_PLANE_DIMENSIONS,165 SIZE_OF_VFX_PLANE_DIMENSIONS,166 100,167 100168 ]}169 />170 {/* <meshStandardMaterial171 attach="material"172 side={THREE.DoubleSide}173 color="#171717"174 metalness={1.0}175 roughness={0}176 /> */}177 <meshPhysicalMaterial178 roughness={0.2}179 roughnessMap={roughnessMap}180 fog={true}181 // displacementMap={null}182 onBeforeCompile={OBC}183 displacementScale={-20.3}184 >185 <canvasTexture186 attach="displacementMap"187 image={defaultCanvasTex.current}188 dispose={null}189 />190 <canvasTexture191 attach="map"192 dispose={null}193 image={defaultCanvasTex.current}194 />195 </meshPhysicalMaterial>196 </mesh>197198 <mesh199 rotation-y={-Math.PI / 2}200 rotation-z={-Math.PI / 2}201 position={[100, -1, 0]}202 scale={[1000, 52.0, 1]}203 dispose={null}204 >205 <planeGeometry />206 <meshStandardMaterial207 color="#bef0ff"208 fog={false}209 emissive="#bef0ff"210 emissiveIntensity={1.6}211 roughnessMap={roughnessMap}212 />213 </mesh>214215 <Environment>216 <Lightformer217 intensity={6.75}218 rotation-y={-Math.PI / 2}219 rotation-z={-Math.PI / 2}220 position={[4, 0, 0]}221 scale={[10, 2.0, 1]}222 color="#bef0ff"223 />224 </Environment>225 </group>226 );227};228229export default function App() {230 return (231 <div className="App">232 <div class="details">233 <p>- Rick Thompson -</p>235 </div>236 <canvas id="canvas" />237 <Canvas238 camera={{ position: [-20, 1, 0] }}239 onCreated={({ gl }) => {240 gl.antiAliasing = true;241 gl.useLegacyLights = true;242 }}243 >244 <OrbitControls245 enableDamping246 minAzimuthAngle={-Math.PI / 1.7}247 maxAzimuthAngle={-Math.PI / 2.4}248 minPolarAngle={Math.PI / 2.6}249 maxPolarAngle={Math.PI - Math.PI / 1.8}250 target={[4, 1, 0]}251 maxDistance={15.0}252 minDistance={5.0}253 />254255 {/* <OrbitControls /> */}256257 <Plane />258 {/* <gridHelper /> */}259 <fog attach="fog" color="black" near={0.1} far={72} />260 <color attach="background" args={["black"]} />261 <Stats />262 {/* <axesHelper scale={[20, 20, 20]} /> */}263 {/* <ambientLight intensity={100} /> */}264265 <EffectComposer multisampling={0} disableNormalPass={true}>266 <Bloom267 luminanceThreshold={0.004}268 luminanceSmoothing={4.9}269 height={300}270 opacity={1.0}271 />272 <Noise opacity={0.2} />273 <Vignette eskil={false} offset={0.07} darkness={1.1} />274 </EffectComposer>275 </Canvas>276 </div>277 );278}
There are a few things:
Plane for the door and increassed emissive properties for the postprocessing bloom
And then the moving ball
Floor is a mixture of a roughness map, noise and shininess
Cracks setup
The plane / door
The door is a plane with some emissive properties set on it to give it a blue tinge, with bloom:
1<mesh2 rotation-y={-Math.PI / 2}3 rotation-z={-Math.PI / 2}4 position={[100, -1, 0]}5 scale={[1000, 52.0, 1]}6 dispose={null}7>8 <planeGeometry />9 <meshStandardMaterial10 color="#bef0ff"11 fog={false}12 emissive="#bef0ff"13 emissiveIntensity={1.6}14 roughnessMap={roughnessMap}15 />16</mesh>1718// more code...1920<EffectComposer multisampling={0} disableNormalPass={true}>21 <Bloom22 luminanceThreshold={0.004}23 luminanceSmoothing={4.9}24 height={300}25 opacity={1.0}26 />27 <Noise opacity={0.2} />28 <Vignette eskil={false} offset={0.07} darkness={1.1} />29</EffectComposer>
This gives the plane its colourful tinge material look and then some glow via postprocessing.
The Moving Ball
A ball is used as a prop to initiate the cracks in the floor. The origin of where it is, is kept and used to move the sphere / passed to the shader for calculations. And the moving is dampened by this library easing
. Which gives its smooth look and feel.
1const xzMoveToVFXOrigin = useRef({ x: 0.0, z: 0.0 });2const xzUVSMoveToVFXOrigin = useRef({ x: 0.0, z: 0.0 });3const tempUvVec2 = new THREE.Vector2();45// code...67useFrame(({ delta, pointer }) => {8 if (vfxPlaneRef.current) {9 // code..1011 if (shader) {12 tempUvVec2.x = xzUVSMoveToVFXOrigin.current.x;13 tempUvVec2.y = xzUVSMoveToVFXOrigin.current.y;14 shader.uniforms.vfxOriginUVs = {15 value: tempUvVec2,16 };17 }18 }19 easing.damp3(20 emptyLookAtRef.current.position,21 [xzMoveToVFXOrigin.current.x, 1.0, xzMoveToVFXOrigin.current.z],22 0.2,23 delta,24 );25});2627// code..2829<mesh dispose={null} ref={emptyLookAtRef} position={[0, 1.0, 0.0]}>30 <sphereGeometry />31 <meshPhysicalMaterial32 roughness={0.0}33 reflectivity={0.0}34 color="black"35 roughnessMap={roughnessMap}36 />37</mesh>
The Floor
So this is a pretty basic setup we have modified the material by using onBeforeCompile. This way we can have all the MeshStandardMaterial properties and modify them slighly for the vfx.
The roughness map is just a grey scaled noise texture which can then be scaled if you want to. Loaded like so:
1const roughnessMap = useLoader(2 THREE.TextureLoader,3 "https://pub-8f66c3b1ef444eb3bd205620d22e9ccd.r2.dev/worley-noise.jpg",4);
The shinniness and reflectivity has been drastically increased on the material. Then the roughness map can be provided to the material in the roughnessMap property and gives patchy less shiny areas.
And the beauty of this is that because we are using onBeforeCompile all we need to do to modify the vertices or roughness is find this shader chunk or relevant code in the main shader file on github and replace this code snippet with itself and some modified code. I will explain later what has been achieved through the shader.
Cracks Setup
The setup consists of creating an arbitary number of cracks per click. And then passing the canvas texture to the maps properties declartively. There are two functions which create the data and then the render function which consumes this data over time, allowing for fine control over animating the off screen canvas texture.
1//Refs:2const defaultCanvasTex = useRef(3 new THREE.CanvasTexture(document.getElementById("canvas")),4);
The default canvas is just the offscreen canvas where we are going to animate the cracks, and we can either just position out of the viewport or just create the element without adding to the DOM or JSX.
Then we Define the maps like so below (this took alot of trial and error to get working):
1<mesh2 dispose={null}3 ref={vfxPlaneRef}4 rotation={[-Math.PI / 2, 0, Math.PI / 2]}5 position={[0, 0.0, 1.0]}6 onPointerMove={({ point, uv }) => {7 if (emptyLookAtRef.current) {8 if (9 point.x > -50.0 &&10 point.x < 10.0 &&11 point.z < 50.0 &&12 point.z > -50.013 ) {14 xzMoveToVFXOrigin.current = {15 x: point.x,16 z: point.z,17 };1819 // emptyLookAtRef.current.position.x = point.x;20 // emptyLookAtRef.current.position.z = point.z;21 emptyLookAtRef.current.lookAt(new THREE.Vector3(100, -1, 0));22 }23 }24 }}25 onClick={({ uv, intersections }) => {26 const point = intersections[0].point;27 xzUVSMoveToVFXOrigin.current = {28 x: uv.x,29 y: 1.0 - uv.y,30 };3132 if (!vfxPlaneClicked.current) {33 fireVFX(point);34 }35 vfxPlaneClicked = true;36 }}37>38 <planeGeometry39 args={[40 SIZE_OF_VFX_PLANE_DIMENSIONS,41 SIZE_OF_VFX_PLANE_DIMENSIONS,42 100,43 100,44 ]}45 />46 {/* <meshStandardMaterial47 attach="material"48 side={THREE.DoubleSide}49 color="#171717"50 metalness={1.0}51 roughness={0}52/> */}53 <meshPhysicalMaterial54 roughness={0.2}55 roughnessMap={roughnessMap}56 fog={true}57 // displacementMap={null}58 onBeforeCompile={OBC}59 displacementScale={-20.3}60 >61 <canvasTexture62 attach="displacementMap"63 image={defaultCanvasTex.current}64 dispose={null}65 />66 <canvasTexture67 attach="map"68 dispose={null}69 image={defaultCanvasTex.current}70 />71 </meshPhysicalMaterial>72</mesh>
I suppose the key bit here is declaring the canvas textures declaritively:
1<meshPhysicalMaterial2 roughness={0.2}3 roughnessMap={roughnessMap}4 fog={true}5 onBeforeCompile={OBC}6 displacementScale={-20.3}7>8 <canvasTexture9 attach="displacementMap"10 image={defaultCanvasTex.current}11 dispose={null}12 />13 <canvasTexture14 attach="map"15 dispose={null}16 image={defaultCanvasTex.current}17 />18</meshPhysicalMaterial>
I found putting this in the colour map and the displacementMap slot to work well.
Shaders
The shaders are modifiying existing code in the material. The shader code looks like so:
1shader.uniforms = {2 ...shader.uniforms,3 ...{4 vfxOriginUVs: {5 value: new THREE.Vector2(),6 },7 },8};
1shader.vertexShader = shader.vertexShader.replace(2 `#include <clipping_planes_pars_vertex>`,3 `4 #include <clipping_planes_pars_vertex>5 varying vec3 vPosition;6 `,7);89shader.vertexShader = shader.vertexShader.replace(10 `#include <displacementmap_pars_vertex>`,11 `12 #ifdef USE_DISPLACEMENTMAP1314 uniform sampler2D displacementMap;15 uniform float displacementScale;16 uniform float displacementBias;17 uniform vec2 vfxOriginUVs;1819 #endif20 `,21);2223shader.vertexShader = shader.vertexShader.replace(24 `#include <displacementmap_vertex>`,25 `26 #ifdef USE_DISPLACEMENTMAP2728 transformed += normalize( objectNormal ) * ( texture2D( displacementMap, vDisplacementMapUv).x * displacementScale + displacementBias );29 vPosition = transformed;30 #endif31 `,32);
First of we declare a varying to be passed to the fragment shader vPosition
. The we do another replace and add some displacement uniforms into the #ifdef
for the original displacement uniforms.
The key bit is that on the mesh code where we define the displacement map we define this aswell:
1<meshPhysicalMaterial2 roughness={0.2}3 roughnessMap={roughnessMap}4 fog={true}5 onBeforeCompile={OBC}6 displacementScale={-20.3}7>
So we want cracks / negative displacement and not positive, so…
1displacementScale={-20.3}
The fragmentShader modifications:
1shader.fragmentShader = shader.fragmentShader.replace(2 `#include <clipping_planes_pars_fragment>`,3 `4 #include <clipping_planes_pars_fragment>5 varying vec3 vPosition;6 `,7);89// Modify the roughness calculation to account for Y threshold10shader.fragmentShader = shader.fragmentShader.replace(11 `#include <roughnessmap_fragment>`,12 `1314 float roughnessFactor = roughness;1516 #ifdef USE_ROUGHNESSMAP1718 vec4 texelRoughness = texture2D( roughnessMap, vRoughnessMapUv * 4.0 );1920 // reads channel G, compatible with a combined OcclusionRoughnessMetallic (RGB) texture21 roughnessFactor *= texelRoughness.g;2223 #endif2425 if (vPosition.z < 0.0) {26 roughnessFactor = mix(0.01, roughnessFactor +0.6, abs(vPosition.z) );27 }28 `,29);3031shader.fragmentShader = shader.fragmentShader.replace(32 `#include <dithering_fragment>`,33 `34 #include <dithering_fragment>353637 if (vPosition.z < -0.5) {38 gl_FragColor.r = mix(mix(0.006,0.008,abs(vPosition.z + 0.5) / 2.0), 0.0017, abs(vPosition.z + 0.6) / 2.3);39 }40 `,41);
So we get the vPosition, this is passed as a varying and then use to create a gradient with this code along the edges of the cracks so its not a sharp change and looks more natural int erms of colours with the scene. Based on how far the vertex (via vPosition) is below the ground mesh. The gradient is as follows:
1if (vPosition.z < -0.5) {2 gl_FragColor.r = mix(3 mix(0.006,0.008,abs(vPosition.z + 0.5) / 2.0),4 0.0017,5 abs(vPosition.z + 0.6) / 2.36 );7}
The mix has two edges, the further up sections (light blue on the cracks) mix(0.006,0.008,abs(vPosition.z + 0.5) / 2.0)
and then the blackish deep bits to the cracks 0.0017
. And then mix these on how deep the crack is: abs(vPosition.z + 0.6) / 2.3
, only using the absolute value to simplify things.
And finally we do some slight modifications and multiplications to the roughness code and also store the roughness value into a roughnessFactor to use in creating a roughness gradient aswell as a colour gradient.
1float roughnessFactor = roughness;23#ifdef USE_ROUGHNESSMAP45 vec4 texelRoughness = texture2D( roughnessMap, vRoughnessMapUv * 4.0 );67 // reads channel G, compatible with a combined OcclusionRoughnessMetallic (RGB) texture8 roughnessFactor *= texelRoughness.g;910#endif
Trigger the Lightning
There is a simple onClick callback which gets fired on the floor mesh:
1onClick={({ uv, intersections }) => {2 const point = intersections[0].point;3 xzUVSMoveToVFXOrigin.current = {4 x: uv.x,5 y: 1.0 - uv.y,6 };78 if (!vfxPlaneClicked.current) {9 fireVFX(point);10 }11 vfxPlaneClicked = true;12}}
The origin of the VFX cracks for the shader is set by passing the uvs to a ref value.
Then pass the point at which the click occured on the floor to the fire function shown above, which kicks of the animated canvas texture and also gets passed into the material declaritively.