Skip to content

Refactoring

Video Lecture

Section Video Links
Refactoring Refactoring Refactoring

Description

The code as it is now is quite large. It is all in one file, it's quite hard to modify, navigate and conceptualise.

In this lesson, we will take the code we have and refactor it into ES6 imports containing a combination of classes and static functions.

The final code should be easier to work with in case we want to adapt each individual component easier.

Working Example

<>

Final Scripts

./src/AdaptiveDPR.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import * as THREE from 'three/webgpu'

let targetFPS = 45
let frameCount = 0
let elapsed = 0

export default function adjustDPR(
  renderer: THREE.WebGPURenderer,
  delta: number
) {
  elapsed += delta
  frameCount++

  if (elapsed >= 0.094) {
    let fps = frameCount * elapsed * 100
    frameCount = 0
    elapsed -= 0.1

    if (fps < targetFPS * 0.95) {
      renderer.setPixelRatio(
        Math.min(1, Math.max(0.1, renderer.getPixelRatio() * 0.9))
      )
    } else if (fps > targetFPS) {
      renderer.setPixelRatio(Math.min(1, renderer.getPixelRatio() * 1.1))
    }

    //console.log(`DPR: ${renderer.getPixelRatio()} (FPS: ${fps})`)
  }
}

./src/AtmosphericScattering.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import {
  clamp,
  exp,
  mix,
  normalize,
  vec3,
  length,
  smoothstep,
  Fn,
} from 'three/tsl'

// @ts-ignore
export const atmosphericScattering = Fn(([position, direction]) => {
  const topColour = vec3(0.1, 0.2, 0.5) // Deep blue (upper sky)
  const midColour = vec3(1.0, 0.4, 0.2) // Orange-red (mid sky)
  const bottomColour = vec3(0.9, 0.6, 0.3) // Yellow near horizon

  const t = clamp(position.y.mul(0.5).add(0.5), 0.0, 1.0) // Horizon-based gradient
  const skyColour = mix(mix(bottomColour, midColour, t), vec3(0), t.mul(1.25))

  const radius = 0.01
  const distance = length(position.sub(normalize(direction)))
  const sun = exp(distance.negate().mul(distance.div(radius)))
  skyColour.addAssign(vec3(1.0, 0.8, 0.5).mul(sun))

  // Mie scattering (orange glow near the sun)
  const mie = exp(distance.mul(3.0).pow(2.0).negate()).mul(0.5)
  const mieColor = vec3(midColour).mul(mie)
  skyColour.addAssign(mieColor.mul(1.5))

  // Rayleigh scattering (blue tint in the upper sky)
  const rayleigh = exp(position.y.mul(2.5)).mul(0.3)
  const rayleighColor = rayleigh.mul(topColour)
  skyColour.addAssign(rayleighColor)

  // Night Transition
  const nightFactor = smoothstep(-0.1, 0.1, direction.y)
  skyColour.mulAssign(nightFactor)

  return skyColour
})

./src/AmbientOcclusion.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import GUI from 'three/examples/jsm/libs/lil-gui.module.min.js'
import { clamp, float, int, Loop, smoothstep, uniform, Fn, If } from 'three/tsl'

export default class AmbientOcclusion {
  private static options = {
    samples: 1,
    spread: 1.367,
    enabled: true,
    factor: 1.373
  }

  private static samples = uniform(this.options.samples)
  private static spread = uniform(this.options.spread)
  private static enabled = uniform(int(this.options.enabled))
  private static factor = uniform(this.options.factor)

  // @ts-ignore
  static render = Fn(([position, normal, scene]) => {
    const diffuse = float(1).toVar()
    If(this.enabled.equal(1), () => {
      const occlusion = float(0.0).toVar()

      Loop({ start: 0, end: this.samples, condition: '<=' }, ({ i }) => {
        const spacer = float(i).div(this.samples)
        const samplePos = position.add(normal.mul(spacer.mul(this.spread)))
        const distance = scene(samplePos)
        //occlusion.addAssign(smoothstep(0.0, 1, distance))
        occlusion.addAssign(smoothstep(0.0, this.factor, distance))
      })
      diffuse.assign(clamp(occlusion.div(this.samples), 0, 1.0))
    })
    return diffuse
  })

  static setGUI(gui: GUI) {
    const folder = gui.addFolder('Ambient Occlusion')
    folder
      .add(this.options, 'samples', 0, 10, 1)
      .name('Samples')
      .onChange((v) => {
        this.samples.value = v
      })
    folder
      .add(this.options, 'spread', 0.001, 5, 0.001)
      .name('spread')
      .onChange((v) => {
        this.spread.value = v
      })
    folder
      .add(this.options, 'factor', 0, 2, 0.001)
      .name('Factor')
      .onChange((v) => {
        this.factor.value = v
      })
    folder.add(this.options, 'enabled').onChange((v) => {
      //@ts-ignore
      this.enabled.value = v
    })
    folder.close()
  }
}

./src/Fog.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import GUI from 'three/examples/jsm/libs/lil-gui.module.min.js'
import { uniform, Fn, mix, exp } from 'three/tsl'

export default class Fog {
  private static options = {
    amount: -0.000001
  }

  private static amount = uniform(this.options.amount)

  // @ts-ignore
  static render = Fn(([colour1, colour2, distance]) => {
    return mix(colour1, colour2, exp(distance.pow(3).mul(this.amount)))
  })

  static setGUI(gui: GUI) {
    const folder = gui.addFolder('Fog')
    folder
      .add(this.options, 'amount', -0.0001, 0.00000001, 0.00000001)
      .name('Amount')
      .onChange((v) => {
        this.amount.value = v
      })

    folder.close()
  }
}

./src/ShadowMarcher.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import GUI from 'three/examples/jsm/libs/lil-gui.module.min.js'
import { abs, Break, clamp, float, If, int, max, min, uniform, Fn, Loop } from 'three/tsl'

export default class ShadowMarcher {
  private static options = {
    softness: 10,
    intensity: 0.29,
    maxSteps: 64,
    near: 0.01,
    far: 64,
    enabled: true,
    surfaceDistance: 0.001
  }

  private static softness = uniform(this.options.softness)
  private static intensity = uniform(this.options.intensity)
  private static maxSteps = uniform(this.options.maxSteps)
  private static near = uniform(this.options.near)
  private static far = uniform(this.options.far)
  private static enabled = uniform(int(this.options.enabled))
  private static surfaceDistance = uniform(this.options.surfaceDistance)

  // @ts-ignore
  static render = Fn(([rayOrigin, rayDirection, scene]) => {
    const shadow = float(1).toVar()
    If(this.enabled.equal(1), () => {
      const accumulatedDistance = float(this.near).toVar()
      const distance = float(0).toVar()

      Loop({ start: 0, end: this.maxSteps, condition: '<' }, () => {
        distance.assign(scene(rayOrigin.add(rayDirection.mul(accumulatedDistance))))
        If(abs(distance.x).lessThan(this.surfaceDistance), () => {
          shadow.assign(0)
          Break()
        }).Else(() => {
          shadow.assign(min(shadow, this.softness.mul(distance.x).div(accumulatedDistance)))
          accumulatedDistance.addAssign(clamp(distance.x, this.surfaceDistance, 10))

          If(accumulatedDistance.greaterThan(this.far), () => {
            Break()
          })
        })
      })

      shadow.assign(max(this.intensity, shadow))
    })
    return shadow
  })

  static setGUI(gui: GUI) {
    const folder = gui.addFolder('Shadows')
    folder
      .add(this.options, 'softness', 0, 200, 0.01)
      .name('Softness')
      .onChange((v) => {
        this.softness.value = v
      })
    folder
      .add(this.options, 'intensity', 0, 1, 0.01)
      .name('Intensity')
      .onChange((v) => {
        this.intensity.value = v
      })
    folder
      .add(this.options, 'maxSteps', 1, 512, 1)
      .name('Max Steps')
      .onChange((v) => {
        this.maxSteps.value = v
      })
    folder
      .add(this.options, 'near', 0, 1, 0.0001)
      .name('Near')
      .onChange((v) => {
        this.near.value = v
      })
    folder
      .add(this.options, 'far', 1, 512, 0.1)
      .name('Far')
      .onChange((v) => {
        this.far.value = v
      })
    folder
      .add(this.options, 'surfaceDistance', 0, 1, 0.0001)
      .name('Surface Distance')
      .onChange((v) => {
        this.surfaceDistance.value = v
      })
    folder
      .add(this.options, 'enabled')
      .name('Enabled')
      .onChange((v) => {
        // @ts-ignore
        this.enabled.value = v
      })
    folder.close()
  }
}

./src/Land.ts

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
import {
  abs,
  clamp,
  cos,
  dot,
  float,
  Fn,
  If,
  Loop,
  mat2,
  mix,
  normalize,
  reflect,
  sin,
  smoothstep,
  uniform,
  vec3
} from 'three/tsl'
import * as THREE from 'three/webgpu'
import { atmosphericScattering } from './AtmosphericScattering'
import AmbientOcclusion from './AmbientOcclusion'
import ShadowMarcher from './ShadowMarcher'
import GUI from 'three/examples/jsm/libs/lil-gui.module.min.js'

export default class Land {
  private static options = {
    octaves: 10,
    lacunarity: 2.15,
    gain: 0.35,
    factorA: 0.19,
    factorB: 2.2,
    colour1: new THREE.Color(0xdfff00).convertLinearToSRGB(),
    colour2: new THREE.Color(0xffffff).convertLinearToSRGB()
  }

  private static octaves = uniform(this.options.octaves)
  private static lacunarity = uniform(this.options.lacunarity)
  private static gain = uniform(this.options.gain)
  private static factorA = uniform(this.options.factorA)
  private static factorB = uniform(this.options.factorB)
  private static colour1 = uniform(this.options.colour1)
  private static colour2 = uniform(this.options.colour2)

  // @ts-ignore
  private static noise = Fn(([position]) => {
    return sin(position.x).add(sin(position.y))
  })

  // @ts-ignore
  private static rotate = Fn(([radians]) => {
    const a = sin(radians)
    const b = cos(radians)
    return mat2(b, a.negate(), a, b)
  })

  // @ts-ignore
  private static fbm = Fn(([position]) => {
    const p = position.xz.toVar()
    const accumulator = float(0.0).toVar()
    const amplitude = float(this.gain).toVar()

    Loop({ start: 0, end: this.octaves, condition: '<' }, () => {
      accumulator.addAssign(amplitude.mul(this.noise(p)))
      amplitude.mulAssign(this.gain)
      //p.mulAssign(lacunarity)
      p.mulAssign(this.lacunarity.mul(this.rotate(this.factorA)))
    })

    return accumulator
  })

  //@ts-ignore
  static scene = Fn(([position]) => {
    const p = position.mul(0.5)
    const height = float(0).toVar()
    If(abs(position.y).lessThan(5), () => {
      height.assign(this.fbm(p).mul(this.factorB))
    })
    return p.y.sub(height)
  })

  //@ts-ignore
  static render = Fn(([position, normal, rayDirection, lightPosition, lightDirection]) => {
    const diffuse = clamp(dot(normal, lightDirection), 0, 1).toVar()

    const colour = atmosphericScattering(reflect(rayDirection, normal), normalize(lightPosition)).mul(this.colour1)

    const steepness = float(abs(normal.y).oneMinus()).toVar()
    colour.assign(mix(colour, this.colour2, smoothstep(0.1, 0.7, steepness)))
    colour.assign(mix(colour, this.colour2, smoothstep(0, 1.0, position.y.sub(2))))

    diffuse.mulAssign(ShadowMarcher.render(position.add(normal.mul(0.001)), vec3(lightDirection), this.scene))

    colour.mulAssign(AmbientOcclusion.render(position, normal, this.scene))

    return colour.mul(diffuse)
  })

  static setGUI(gui: GUI) {
    const folder = gui.addFolder('Land')
    folder
      .add(this.options, 'octaves', 0, 10, 1)
      .name('Octaves')
      .onChange((v) => {
        this.octaves.value = v
      })
    folder
      .add(this.options, 'lacunarity', 1, 10, 0.01)
      .name('Lacunarity')
      .onChange((v) => {
        this.lacunarity.value = v
      })
    folder
      .add(this.options, 'gain', 0.01, 1.99, 0.01)
      .name('Gain')
      .onChange((v) => {
        this.gain.value = v
      })
    folder
      .add(this.options, 'factorA', 0, Math.PI / 2, 0.01)
      .name('Factor A')
      .onChange((v) => {
        this.factorA.value = v
      })
    folder
      .add(this.options, 'factorB', -10, 10, 0.01)
      .name('Factor B')
      .onChange((v) => {
        this.factorB.value = v
      })
    folder.addColor(this.options, 'colour1').name('Colour 1')
    folder.addColor(this.options, 'colour2').name('Colour 2')

    folder.close()
  }
}

./src/SDFScene.ts

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
import { Break, cos, float, If, Loop, normalize, sin, time, uniform, vec2, vec3, Fn, positionLocal } from 'three/tsl'
import * as THREE from 'three/webgpu'
import { atmosphericScattering } from './AtmosphericScattering'
import Land from './Land'
import ShadowMarcher from './ShadowMarcher'
import AmbientOcclusion from './AmbientOcclusion'
import Fog from './Fog'
import GUI from 'three/examples/jsm/libs/lil-gui.module.min.js'

export default class SDFScene {
  private static options = {
    maxSteps: 256,
    surfaceDistance: 0.001,
    cameraNear: 0.001,
    cameraFar: 256.0,
    maxReflections: 0,
    reflectivity: 0.5
  }

  private static maxSteps = uniform(this.options.maxSteps)
  private static surfaceDistance = uniform(this.options.surfaceDistance)
  private static cameraNear = uniform(this.options.cameraNear)
  private static cameraFar = uniform(this.options.cameraFar)

  // @ts-ignore
  private static scene = Fn(([position]) => {
    return Land.scene(position)
  })

  // @ts-ignore
  private static getNormal = Fn(([position, distance]) => {
    const offset = vec2(0.0001, 0)

    return normalize(
      distance.sub(
        vec3(
          this.scene(position.sub(offset.xyy)).x,
          this.scene(position.sub(offset.yxy)).x,
          this.scene(position.sub(offset.yyx)).x
        )
      )
    )
  })

  // @ts-ignore
  static render = Fn(([rayOrigin]) => {
    const p = positionLocal
    const rayDirection = normalize(p)

    const t = time.div(5)
    //const lightPosition = vec3(-25, 7.5, -25)
    //const lightPosition = vec3(-25, sin(time).mul(10).add(7), -25)
    //const lightPosition = vec3(sin(t).mul(cameraFar), 25, cos(t).mul(cameraFar))
    const lightPosition = vec3(sin(t).mul(this.cameraFar), sin(t).mul(50).add(40), cos(t).mul(this.cameraFar))
    const lightDirection = normalize(lightPosition.sub(p))

    const skyColour = atmosphericScattering(p, normalize(lightPosition))

    const accumulatedDistance = float(this.cameraNear).toVar()
    const distance = vec2(0).toVar()
    const position = vec3(0).toVar()

    Loop({ start: 0, end: this.maxSteps, condition: '<' }, () => {
      position.assign(rayOrigin.add(rayDirection.mul(accumulatedDistance)))
      distance.assign(this.scene(position))

      If(distance.x.lessThan(this.surfaceDistance).or(accumulatedDistance.greaterThan(this.cameraFar)), () => {
        Break()
      })

      accumulatedDistance.addAssign(distance.x.mul(0.5))
    })

    const normal = this.getNormal(position, distance.x)

    const finalColour = Land.render(position, normal, rayDirection, lightPosition, lightDirection).toVar()

    finalColour.assign(Fog.render(skyColour, finalColour, accumulatedDistance))

    return finalColour
  })

  static setGUI(gui: GUI) {
    const folder = gui.addFolder('Raymarching')
    folder
      .add(this.options, 'maxSteps', 1, 512, 1)
      .name('Raymarch Max Steps')
      .onChange((v) => {
        this.maxSteps.value = v
      })
    folder
      .add(this.options, 'surfaceDistance', 0, 0.001, 0.0001)
      .name('Surface Distance')
      .onChange((v) => {
        this.surfaceDistance.value = v
      })
    folder
      .add(this.options, 'cameraNear', 0.0001, 10, 0.1)
      .name('Camera Near')
      .onChange((v) => {
        this.cameraNear.value = v
      })
    folder
      .add(this.options, 'cameraFar', 1, 512, 0.1)
      .name('Camera Far')
      .onChange((v) => {
        this.cameraFar.value = v
      })
    folder.close()

    ShadowMarcher.setGUI(gui)
    AmbientOcclusion.setGUI(gui)
    Land.setGUI(gui)
    Fog.setGUI(gui)
  }
}

./src/main.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import './style.css'
import * as THREE from 'three/webgpu'
import { uniform } from 'three/tsl'
import { FirstPersonControls } from 'three/addons/controls/FirstPersonControls.js'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
import adjustDPR from './AdaptiveDPR'
import SDFScene from './SDFScene'

const scene = new THREE.Scene()

const camera = new THREE.PerspectiveCamera(
  53,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
)
camera.position.set(0, 1.5, 0)

const renderer = new THREE.WebGPURenderer()
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
renderer.setAnimationLoop(animate)
renderer.setPixelRatio(0.25) // start low for slower cards

window.addEventListener('resize', function () {
  camera.aspect = window.innerWidth / window.innerHeight
  camera.updateProjectionMatrix()
  renderer.setSize(window.innerWidth, window.innerHeight)
  controls.handleResize()
})

const controls = new FirstPersonControls(camera, renderer.domElement)
controls.movementSpeed = 10
controls.lookSpeed = 0.2
controls.activeLook = false

renderer.domElement.addEventListener('mousedown', function () {
  controls.activeLook = true
})

renderer.domElement.addEventListener('mouseup', function () {
  controls.activeLook = false
})

const gui = new GUI()

SDFScene.setGUI(gui)

const camPos = uniform(new THREE.Vector3())

scene.backgroundNode = SDFScene.render(camPos.toVar())

const clock = new THREE.Clock()
let delta = 0

function animate() {
  delta = clock.getDelta()
  adjustDPR(renderer, delta) //Adaptive DPR

  controls.update(delta)

  camPos.value.copy(camera.position)

  renderer.render(scene, camera)
}