Creating a Ghost Shader with TSL
Have you ever wonder how games like Divinity Original Sin 2 or
Baldur's Gate 3 create their spectral ghost effects? In this post I will show you how to create a similar effect using
ThreeJS TSL, the node-based shader system that ships with Three.js's WebGPU renderer.
We want a material that:
- Glows at the edges — bright silhouette, transparent center
- Emits a spectral cyan light — feels otherworldly
- Is fully transparent — no solid surface, just energy
- Renders from both sides

The Fresnel Effect
The Fresnel effect is the foundation of this shader. In the real world, surfaces reflect more light at glancing angles — think of how a lake looks transparent when you look straight down but mirrors the sky at the horizon.
Our ghost material exploits this: where the surface faces you directly, it's transparent. Where it faces away — the edges — it glows.
The formula
The core of the effect is a dot product between the surface normal N and the view direction V:
- N — the surface normal at the fragment (which way the surface is pointing)
- V — the direction from the fragment toward the camera
- p — falloff sharpness; higher values tighten the glow to a thinner rim
When you look head-on, N and V are nearly parallel → N·V ≈ 1 → F ≈ 0 (transparent). At grazing angles they're near-perpendicular → N·V ≈ 0 → F ≈ 1 (bright edge).
The ghost shader uses p = 1.5 for this intermediate value, then passes it through a smoothstep to reshape the curve — we'll cover that in the TSL implementation below.
Building It Step by Step
Step 1: A Plain Sphere
Before any shader magic, here's our starting mesh — a plain MeshPhysicalNodeMaterial with default settings.
import { MeshPhysicalNodeMaterial } from 'three/webgpu'
const material = new MeshPhysicalNodeMaterial()
Step 2: Fresnel Opacity
Now we compute the Fresnel factor and pipe it into opacityNode. The center of the sphere becomes transparent, the edges glow.
import { color, dot, normalView, float, positionViewDirection, vec3, pow, sub, smoothstep } from 'three/tsl'
import { DoubleSide } from 'three'
import { MeshPhysicalNodeMaterial } from 'three/webgpu'
const NdotV = dot(normalView, positionViewDirection).abs()
const fresnelFactor = pow(sub(float(1.0), NdotV), float(1.5)).mul(0.9)
const shaped = smoothstep(float(0.0), float(1.0), fresnelFactor)
const material = new MeshPhysicalNodeMaterial()
material.transparent = true
material.depthWrite = false
material.side = DoubleSide
material.opacityNode = shaped
Each line maps directly to the formula :
normalView→ N in the formula. The surface normal in view (camera) space.positionViewDirection→ V in the formula. The direction from the fragment toward the camera.dot(normalView, positionViewDirection).abs()→ N·V. Gives 1 when you're looking straight at the surface, 0 at grazing angles..abs()handles back faces.- `sub(float(1.0), NdotV)` → (1 - N·V). Inverts it: now edges = 1, center = 0.
pow(..., float(1.5))→ the exponent p. Controls falloff sharpness —1.5gives a medium rim,3.0tightens it,0.5spreads it wider.smoothstep(0, 1, fresnelFactor)— not in the base formula, but reshapes the raw ramp with an S-curve so the transition feels organic rather than linear.
depthWrite = false is critical — without it, the transparent regions still occlude objects behind them. DoubleSide ensures both faces render so the sphere looks consistent from any angle.
Step 3: Adding Emission
The shape is right, but there's no light yet. We set colorNode to black (no diffuse surface) and drive emissiveNode with the same Fresnel shape multiplied by a spectral cyan.
material.colorNode = vec3(0, 0, 0)
material.emissiveNode = color('#88ccff').mul(shaped).mul(12.0)
The 12.0 multiplier pushes the emission into HDR range — values above 1.0 will bloom when a post-processing pass is active.
Step 4: Bloom
The HDR emission values are there but the glow looks flat without bloom. Adding a post-processing bloom pass makes the light bleed into surrounding pixels.
import { bloom } from 'three/addons/tsl/display/BloomNode.js'
// Add to your post-processing pipeline
const bloomPass = bloom(scenePassColor)
bloomPass.strength.value = 0.5
bloomPass.threshold.value = 0.1
The Complete Material
Putting it all together in a reusable function:
import {
color,
dot,
normalView,
float,
positionViewDirection,
vec3,
pow,
sub,
smoothstep,
} from 'three/tsl'
import { DoubleSide } from 'three'
import { MeshPhysicalNodeMaterial } from 'three/webgpu'
export function ghostMaterial() {
const NdotV = dot(normalView, positionViewDirection).abs()
const fresnelFactor = pow(sub(float(1.0), NdotV), float(1.5)).mul(0.9)
const shaped = smoothstep(float(0.0), float(1.0), fresnelFactor)
const material = new MeshPhysicalNodeMaterial()
material.transparent = true
material.depthWrite = false
material.side = DoubleSide
material.colorNode = vec3(0, 0, 0)
material.opacityNode = shaped
material.emissiveNode = color('#88ccff').mul(shaped).mul(12.0)
return material
}