Skip to content

Kleinian Inversion Fractal

Video Lecture

Section Video Links
Kleinian Inversion Fractal Kleinian Inversion Fractal Kleinian Inversion Fractal 

 (Pay Per View)

You can use PayPal to purchase a one time viewing of this video for $1.49 USD.

Pay Per View Terms

  • One viewing session of this video will cost the equivalent of $1.49 USD in your currency.
  • After successful purchase, the video will automatically start playing.
  • You can pause, replay and go fullscreen as many times as needed in one single session for up to an hour.
  • Do not refresh the browser since it will invalidate the session.
  • If you want longer-term access to all videos, consider purchasing full access through Udemy or YouTube Memberships instead.
  • This Pay Per View option does not permit downloading this video for later viewing or sharing.
  • All videos are Copyright © 2019-2025 Sean Bradley, all rights reserved.

Description

Also known as a Kleinian/Apollonian fractal.

These fractals are generated by repeatedly reflecting spheres inside other spheres to produce intricate nested spherical patterns.

  • Kleinian : A distance-estimated 3D fractal made from repeated inversions in spheres (and sometimes planes).

  • Apollonian : Refers to Apollonius of Perga (ancient Greek mathematician) and typically means a construction where circles (or spheres in 3D) are packed so that every gap is filled with more circles/spheres.

Working Example

<>

Kleinian Inversion Algorithm

  1. Pass in a position coordinate.
  2. Initialise a scale factor that will track how much the space has been inverted (scaled) through iterations.
  3. Initialise minRadius to a very large number. It will store the smallest radius encountered during the iterations.
  4. Iterate items 5-11 until finished.
  5. Compute the radiusSquared distance of the position from the origin.
  6. Checks if the point lies inside the inversion sphere.
  7. Compute the inversion factor k based on the Kleinian inversion formula. This effectively maps points inside the sphere to outside and vice versa.
  8. Scales (invert) the position using the computed factor k.
  9. Accumulate the scale transformation applied so far (needed for final distance calculation).
  10. Perform box folding which mirrors the space at the box boundaries to create repeating fractal structure.
  11. Updates minRadius to store the smallest radius encountered across all inversions.
  12. Return the distance adjusted by scale to account for the transformations, and multiplied by 0.5 for normalisation.

The combination of inversion plus folding creates the recursive structure typical of a Kleinian Inversion fractal.

Start Scripts

./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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
import * as THREE from 'three/webgpu'
import {
  Break,
  float,
  If,
  Loop,
  normalize,
  uniform,
  vec2,
  vec3,
  Fn,
  positionLocal,
  abs,
  ShaderNodeObject
} from 'three/tsl'
import GUI from 'three/examples/jsm/libs/lil-gui.module.min.js'
import AmbientOcclusion from './AmbientOcclusion'
import { atmosphericScattering } from './AtmosphericScattering'
import Kleinian from './Kleinian'
import Fog from './Fog'

export default class SDFScene {
  private static options = {
    maxSteps: 256,
    surfaceDistance: 0.0001,
    cameraNear: 0.1,
    cameraFar: 100.0
  }

  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)

  //
  // private static rand = Fn(([seed]:[ShaderNodeObject<THREE.Node>]) => {
  //   // Dot product to combine components into a single value
  //   const dotVal = dot(seed, vec3(12.3456, 78.9012, 34.5678))
  //   // Sine-based hash, fract to keep in [0,1]
  //   return dotVal.mul(34567.89012).fract().sin() //.mul(0.25)
  // })

  private static scene = Fn(([position]: [ShaderNodeObject<THREE.Node>]) => {
    return vec2(Kleinian.scene(position), 0).toVar()
  })

  private static getNormal = Fn(
    ([position, distance]: [ShaderNodeObject<THREE.Node>, ShaderNodeObject<THREE.Node>]) => {
      const offset = vec2(0.0025, 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
          )
        )
      )
    }
  )

  static render = Fn(([rayOrigin_immutable]: [ShaderNodeObject<THREE.Node>]) => {
    const rayOrigin = rayOrigin_immutable.toVar()

    const p = positionLocal

    const rayDirection = normalize(p).toVar()

    //const t = time //.div(5)
    const lightPosition = vec3(0, 50, this.cameraFar.negate())
    //const lightPosition = vec3(0, sin(t).mul(30).add(43), this.cameraFar.negate())
    //const lightPosition = vec3(sin(t).mul(this.cameraFar), 50, cos(t).mul(this.cameraFar))
    //const lightPosition = vec3(sin(t).mul(this.cameraFar), sin(t).mul(40).add(50), cos(t).mul(this.cameraFar))
    const lightDirection = normalize(lightPosition.sub(p)).toVar()

    const skyColour = atmosphericScattering(p, normalize(lightPosition)).toVar()
    const finalColour = skyColour.toVar()

    const accumulatedDistance = float(this.cameraNear).toVar() //float(this.rand(p)).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(abs(distance.x).lessThan(this.surfaceDistance).or(accumulatedDistance.greaterThan(this.cameraFar)), () => {
        Break()
      })

      accumulatedDistance.addAssign(distance.x) //.mul(0.6))
    })

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

    If(accumulatedDistance.lessThan(this.cameraFar), () => {
      finalColour.assign(Kleinian.render(position, normal, rayDirection, lightPosition, lightDirection))
    })

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

    return finalColour
  })

  static setGUI(gui: GUI) {
    const folder = gui.addFolder('SDF Scene')
    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.01, 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()

    Kleinian.setGUI(gui)
    AmbientOcclusion.setGUI(gui)
    Fog.setGUI(gui)
  }
}

./src/Kleinian.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
import * as THREE from 'three/webgpu'
import { clamp, dot, float, Fn, uniform, vec3, length, Loop, If, min, ShaderNodeObject } from 'three/tsl'
import GUI from 'three/examples/jsm/libs/lil-gui.module.min.js'
import AmbientOcclusion from './AmbientOcclusion'

export default class Kleinian {
  private static options = {
    iterations: 8,
    foldSize: 1.2,
    sphereRadius: 1.26
    //organic: 0
  }

  private static iterations = uniform(this.options.iterations)
  private static foldSize = uniform(this.options.foldSize)
  private static sphereRadius = uniform(this.options.sphereRadius)
  //private static organic = uniform(this.options.organic)

  static scene = Fn(([position]: [ShaderNodeObject<THREE.Node>]) => {
    const p = position.toVar()

    const scale = float(1.0).toVar()
    const minRadius = float(1e9).toVar()

    Loop({ start: 0, end: this.iterations, condition: '<' }, () => {
      // Sphere inversion (in sphere of radius sphereRadius)
      const radiusSquared = dot(p, p) //.add(this.organic))

      If(radiusSquared.lessThan(this.sphereRadius.mul(this.sphereRadius)), () => {
        const k = this.sphereRadius.mul(this.sphereRadius).div(radiusSquared)
        p.mulAssign(k)
        scale.mulAssign(k)
      })

      // Box fold - reflect into cube [-foldSize, foldSize]
      p.assign(clamp(p, this.foldSize.negate(), this.foldSize).mul(2).sub(p))
      minRadius.assign(min(minRadius, length(p)))
    })

    return minRadius.div(scale).mul(0.5)
  })

  static render = Fn(
    ([position, normal, rayDirection, lightPosition, lightDirection]: [
      ShaderNodeObject<THREE.Node>,
      ShaderNodeObject<THREE.Node>,
      ShaderNodeObject<THREE.Node>,
      ShaderNodeObject<THREE.Node>,
      ShaderNodeObject<THREE.Node>
    ]) => {
      const diffuse = clamp(dot(normal, lightDirection), 0, 1).toVar()
      const colour = vec3(0.4, 0.9, 0.7).toVar()
      colour.mulAssign(AmbientOcclusion.render(position, normal, this.scene))
      return colour
        .mul(float(0.2).add(diffuse.mul(0.4)))
        .mul(normal.mul(0.5).add(0.5))
        .pow(0.5)
    }
  )

  static setGUI(gui: GUI) {
    const folder = gui.addFolder('Kleinian')

    folder
      .add(this.options, 'iterations', 1, 20, 1)
      .name('Iterations')
      .onChange((v) => {
        this.iterations.value = v
      })
    folder
      .add(this.options, 'foldSize', 0.5, 2.0, 0.01)
      .name('FoldSize')
      .onChange((v) => {
        this.foldSize.value = v
      })
    folder
      .add(this.options, 'sphereRadius', 0.5, 2.0, 0.01)
      .name('SphereRadius')
      .onChange((v) => {
        this.sphereRadius.value = v
      })
    // folder
    //   .add(this.options, 'organic', -2, 2, 0.001)
    //   .name('Organic')
    //   .onChange((v) => {
    //       this.organic.value = v
    //   })
    //folder.close()
  }
}

./src/main.ts

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,
  10
)
camera.position.set(1, 1, 2.25)

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)
})

const controls = new FirstPersonControls(camera, renderer.domElement)
controls.movementSpeed = 0.25
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)
}

Fog Settings

Property Value
Amount -0.001

Box Fold (sbedit)

Repeat/Tile (sbedit)

Fractal Worlds