Easy methods to Create a Liquid Raymarching Scene Utilizing Three.js Shading Language

0
26
How to Create a Liquid Raymarching Scene Using Three.js Shading Language


metaballs

I’ve at all times been fascinated by shaders. The concept items of code can create a few of the most awe-inspiring visuals you see in video video games, films, and on the net has pushed me to be taught as a lot as I can about them.

Throughout that journey, I got here throughout a video by Inigo Quilez referred to as Portray a Character with Maths. It’s a frankly mind-blowing instance of a way referred to as Raymarching. Basically, it’s a solution to construct or render complicated 2D and 3D scenes in a single fragment shader without having complicated fashions or supplies.

Whereas that instance is admittedly spectacular, it is usually fairly intimidating! So, to ease us into this idea, we’ll discover issues just like metaballs, these extraordinarily cool-looking gloopy, liquid shapes that appear to soak up into one another in fascinating methods.

Raymarching is a big subject to cowl, however there are some wonderful, in-depth sources and tutorials obtainable when you’re fascinated about going loads deeper. For this tutorial, we’re going to base the raymarching methods on this tutorial by Kishimisu: An Introduction to Raymarching, with many references to 3D SDF Sources by Inigo Quilez. Should you’re after one thing extra in-depth, I extremely suggest the excellently written Portray with Math: A Light Examine of Raymarching by Maxime Heckel.

On this tutorial, we are going to construct a easy raymarched scene with fascinating lighting utilizing React Three Fiber (R3F) and Three.js Shader Language (TSL). You have to some information of Three.js and React, however the methods right here might be utilized in any shading language equivalent to GLSL, and any WebGL framework (so, OGL or vanilla is completely potential).

The Setup

We’re going to be utilizing Three.js Shading Language, a brand new and evolving language that goals to decrease the barrier of entry for creating shaders by offering an approachable surroundings for many who aren’t so aware of issues like GLSL or WGSL.

TSL requires the WebGPURenderer in Three.js in the intervening time. Which means that if WebGPU is accessible, the TSL we write will compile all the way down to WGSL (the shading language utilized in WebGPU) and can fall again to GLSL (WebGL) if wanted. As we’re utilizing R3F, we’ll arrange a really primary canvas and scene with a single airplane, in addition to a uniform that incorporates details about the display screen decision that we’ll use in our raymarched scene. First, we have to arrange the Canvas in R3F:

import { Canvas, CanvasProps } from '@react-three/fiber'
import { useEffect, useState } from 'react'

import { AdaptiveDpr } from '@react-three/drei'

import WebGPUCapabilities from 'three/examples/jsm/capabilities/WebGPU.js'
import WebGPURenderer from 'three/examples/jsm/renderers/webgpu/WebGPURenderer.js'
import { ACESFilmicToneMapping, SRGBColorSpace } from 'three'

const WebGPUCanvas = ({
  webglFallback = true,
  frameloop = 'at all times',
  kids,
  debug,
  ...props
}) => {
  const [canvasFrameloop, setCanvasFrameloop] = useState('by no means')
  const [initialising, setInitialising] = useState(true)

  useEffect(() => {
    if (initialising) return

    setCanvasFrameloop(frameloop)
  }, [initialising, frameloop])

  const webGPUAvailable = WebGPUCapabilities.isAvailable()

  return (
     {
        const renderer = new WebGPURenderer({
          canvas: canvas,
          antialias: true,
          alpha: true,
          forceWebGL: !webGPUAvailable,
        })
        renderer.toneMapping = ACESFilmicToneMapping
        renderer.outputColorSpace = SRGBColorSpace
        renderer.init().then(() => {
          setInitialising(false)
        })

        return renderer
      }}
    >
      

      {kids}
    
  )
}

Now that we’ve set this up, let’s create a primary part for our scene utilizing a MeshBasicNodeMaterial the place we are going to write our shader code. From right here, all of our code will probably be written for this materials.

import { useThree } from '@react-three/fiber'

import {
  MeshBasicNodeMaterial,
  uniform,
  uv,
  vec3,
  viewportResolution
} from 'three/nodes'

const raymarchMaterial = new MeshBasicNodeMaterial()

raymarchMaterial.colorNode = vec3(uv(), 1)

const Raymarch = () => {
  const { width, peak } = useThree((state) => state.viewport)

  return (
    
      
      
    
  )
}

Creating the Raymarching Loop

Raymarching, at its most basic, involves stepping along rays cast from an origin point (such as a camera) in small increments (known as marching) and testing for intersections with objects in the scene. This process continues until an object is hit, or if we reach a maximum distance from the origin point. As this is handled in a fragment shader, this process happens for every output image pixel in the scene. (Note that all new functions such as float or vec3 are imports from three/nodes).

const sdf = tslFn(([pos]: any) => {
  // That is our fundamental "scene" the place objects will go, however for now return 0
  return float(0)
})

const raymarch = tslFn(() => {
  // Use frag coordinates to get an aspect-fixed UV
  const _uv = uv().mul(viewportResolution.xy).mul(2).sub(viewportResolution.xy).div(viewportResolution.y)

  // Initialize the ray and its path
  const rayOrigin = vec3(0, 0, -3)
  const rayDirection = vec3(_uv, 1).normalize()

  // Whole distance travelled - notice that toVar is vital right here so we will assign to this variable
  const t = float(0).toVar()

  // Calculate the preliminary place of the ray - this var is said right here so we will use it in lighting calculations later
  const ray = rayOrigin.add(rayDirection.mul(t)).toVar()

  loop({ begin: 1, finish: 80 }, () => {
    const d = sdf(ray) // present distance to the scene

    t.addAssign(d) // "march" the ray

    ray.assign(rayOrigin.add(rayDirection.mul(t))) // place alongside the ray

    // If we're shut sufficient, it is a hit, so we will do an early return
    If(d.lessThan(0.001), () => {
      Break()
    })

    // If we have travelled too far, we will return now and contemplate that this ray did not hit something
    If(t.greaterThan(100), () => {
      Break()
    })
  })

  // Some very primary shading right here - objects which can be nearer to the rayOrigin will probably be darkish, and objects additional away will probably be lighter
  return vec3(t.mul(0.2))
})()

raymarchMaterial.colorNode = raymarch

What you would possibly discover right here is that we’re not really testing for actual intersections, and we’re not utilizing mounted distances for every of our steps. So, how do we all know if our ray has “hit” an object within the scene? The reply is that the scene is made up of Signed Distance Fields (SDFs).

SDFs are primarily based on the idea of calculating the shortest distance from any level in area to the floor of a form. So, the worth returned by an SDF is constructive if the purpose is exterior the form, unfavorable if inside, and nil precisely on the floor.

With this in thoughts, we actually solely want to find out if a ray is “shut sufficient” to a floor for it to be successful. Every successive step travels the space to the closest floor, so as soon as we cross some small threshold near 0, we’ve successfully “hit” a floor, permitting us to do an early return.

(If we stored marching till the space was 0, we’d successfully simply preserve operating the loop till we ran out of iterations, which—whereas it could get the end result we’re after—is loads much less environment friendly.)

Including SDF Shapes

Our SDF perform here’s a comfort perform to construct the scene. It’s a spot the place we will add some SDF shapes, manipulating the place and attributes of every form to get the end result that we wish. Let’s begin with a sphere, rendering it within the middle of the viewport:

const sdSphere = tslFn(([p, r]) => {
  return p.size().sub(r)
})

const sdf = tslFn(([pos]) => {
  // Replace the sdf perform so as to add our sphere right here
  const sphere = sdSphere(pos, 0.3)

  return sphere
})
Screenshot 2024 07 01 at 1.44.57 PM

We are able to change how huge or small it’s by altering the radius, or by altering its place alongside the z axis (so nearer, or additional away from the origin level)

That is the place we will additionally do another cool stuff, like change its place primarily based on time and a sin curve (notice that each one of those new capabilities equivalent to sin, or timerLocal are all imports from three/nodes):

const timer = timerLocal(1)

const sdf = tslFn(([pos]) => {
  // Translate the place alongside the x-axis so the form strikes left to proper
  const translatedPos = pos.add(vec3(sin(timer), 0, 0))
  const sphere = sdSphere(translatedPos, 0.5)

  return sphere
})

// Notice: that we will additionally use oscSine() rather than sin(timer), however as it's within the vary
// 0 to 1, we have to remap it to the vary -1 to 1
const sdf = tslFn(([pos]) => {
  const translatedPos = pos.add(vec3(oscSine().mul(2).sub(1), 0, 0))
  const sphere = sdSphere(translatedPos, 0.5)

  return sphere
})

Now we will add a second sphere in the course of the display screen that doesn’t transfer, so we will present the way it matches within the scene:

const sdf = tslFn(([pos]: any) => {
  const translatedPos = pos.add(vec3(sin(timer), 0, 0))
  const sphere = sdSphere(translatedPos, 0.5)
  const secondSphere = sdSphere(pos, 0.3)

  return min(secondSphere, sphere)
})

See how we use the min perform right here to mix the shapes once they overlap. This takes two enter SDFs and determines the closest one, successfully making a single subject. However the edges are sharp; the place’s the gloopiness? That’s the place some extra math comes into play.

Easy Minimal: The Secret Sauce

Easy Minimal is minimal, however easy! Inigo Quilez’s article is one of the best useful resource for extra details about how this works, however let’s implement it utilizing TSL and see the end result:

const smin = tslFn(([a, b, k]: any) => {
  const h = max(ok.sub(abs(a.sub(b))), 0).div(ok)
  return min(a, b).sub(h.mul(h).mul(ok).mul(0.25))
})

const sdf = tslFn(([pos]: any) => {
  const translatedPos = pos.add(vec3(sin(timer), 0, 0))
  const sphere = sdSphere(translatedPos, 0.5)
  const secondSphere = sdSphere(pos, 0.3)

  return smin(secondSphere, sphere, 0.3)
})

Right here it’s! Our gloopiness! However the end result right here is fairly flat, so let’s do some lighting to get a extremely cool look

Including Lighting

Up up to now, we’ve been working with quite simple, flat shading primarily based on the space to a specific floor, so our scene “appears” 3D, however we will make it look actually cool with some lighting

Including lighting is a good way to create depth and dynamism, so let’s add quite a lot of completely different lighting results in TSL. This part is a little bit of an “added additional,” so I gained’t go into each sort of lighting. Should you’d prefer to be taught extra in regards to the lighting used right here and shaders on the whole, right here is a superb paid course that I completely suggest: https://simondev.teachable.com/p/glsl-shaders-from-scratch.

On this demo, we’re going so as to add ambient lighting, hemisphere lighting, diffuse and specular lighting, and a fresnel impact. This feels like loads, however every of those lighting results is just a few traces every! For a lot of of those methods, we might want to calculate normals, once more due to Inigo Quilez.

const calcNormal = tslFn(([p]) => {
  const eps = float(0.0001)
  const h = vec2(eps, 0)
  return normalize(
    vec3(
      sdf(p.add(h.xyy)).sub(sdf(p.sub(h.xyy))),
      sdf(p.add(h.yxy)).sub(sdf(p.sub(h.yxy))),
      sdf(p.add(h.yyx)).sub(sdf(p.sub(h.yyx))),
    ),
  )
})

const raymarch = tslFn(() => {
  // Use frag coordinates to get an aspect-fixed UV
  const _uv = uv().mul(decision.xy).mul(2).sub(decision.xy).div(decision.y)

  // Initialize the ray and its path
  const rayOrigin = vec3(0, 0, -3)
  const rayDirection = vec3(_uv, 1).normalize()

  // Whole distance travelled - notice that toVar is vital right here so we will assign to this variable
  const t = float(0).toVar()

  // Calculate the preliminary place of the ray - this var is said right here so we will use it in lighting calculations later
  const ray = rayOrigin.add(rayDirection.mul(t)).toVar()

  loop({ begin: 1, finish: 80 }, () => {
    const d = sdf(ray) // present distance to the scene

    t.addAssign(d) // "march" the ray

    ray.assign(rayOrigin.add(rayDirection.mul(t))) // place alongside the ray

    // If we're shut sufficient, it is a hit, so we will do an early return
    If(d.lessThan(0.001), () => {
      Break()
    })

    // If we have travelled too far, we will return now and contemplate that this ray did not hit something
    If(t.greaterThan(100), () => {
      Break()
    })
  })

  return lighting(rayOrigin, ray)
})()

A standard is a vector that’s perpendicular to a different vector, so on this case, you possibly can consider normals as how mild will work together with the floor of the item (consider how mild bounces off a floor). We’ll use these in lots of our lighting calculations:

const lighting = tslFn(([ro, r]) => {
  const regular = calcNormal(r)
  const viewDir = normalize(ro.sub(r))

  // Step 1: Ambient mild
  const ambient = vec3(0.2)

  // Step 2: Diffuse lighting - provides our form a 3D look by simulating how mild displays in all instructions
  const lightDir = normalize(vec3(1, 1, 1))
  const lightColor = vec3(1, 1, 0.9)
  const dp = max(0, dot(lightDir, regular))

  const diffuse = dp.mul(lightColor)

  // Steo 3: Hemisphere mild - a mixture between a sky and floor color primarily based on normals
  const skyColor = vec3(0, 0.3, 0.6)
  const groundColor = vec3(0.6, 0.3, 0.1)

  const hemiMix = regular.y.mul(0.5).add(0.5)
  const hemi = combine(groundColor, skyColor, hemiMix)

  // Step 4: Phong specular - Reflective mild and highlights
  const ph = normalize(mirror(lightDir.negate(), regular))
  const phongValue = max(0, dot(viewDir, ph)).pow(32)

  const specular = vec3(phongValue).toVar()

  // Step 5: Fresnel impact - makes our specular spotlight extra pronounced at completely different viewing angles
  const fresnel = float(1)
    .sub(max(0, dot(viewDir, regular)))
    .pow(2)

  specular.mulAssign(fresnel)

  // Lighting is a mixture of ambient, hemi, diffuse, then specular added on the finish
  // We're multiplying these all by completely different values to regulate their depth

  // Step 1
  const lighting = ambient.mul(0.1)

  // Step 2
  lighting.addAssign(diffuse.mul(0.5))

  // Step 3
  lighting.addAssign(hemi.mul(0.2))

  const finalColor = vec3(0.1).mul(lighting).toVar()

  // Step 4 & 5
  finalColor.addAssign(specular)

  return finalColor
})

The place to go from right here

So we did it! There was loads to be taught, however the end result might be spectacular, and from right here there’s a lot that you are able to do with it. Listed below are some issues to attempt:

  • Add a dice or a rectangle and rotate it.
  • Add some noise to the shapes and get gnarly with it.
  • Discover different combining capabilities (max).
  • Use fract or mod for some fascinating area repetition.

I hope you loved this mild introduction to raymarching and TSL. When you have any questions, let me know on X.

Credit and References



Supply hyperlink

LEAVE A REPLY

Please enter your comment!
Please enter your name here