Skip to content
pmndrsPublic

About

Full WebGPU sky system based on the Unreal Engine sky by Sébastien Hillaire

Resources

Stars

9 stars

Watchers

0 watching

Forks

Latest commit

 

History

119 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@pmndrs/sky

A hyper-efficient WebGPU sky system for three.js.

npm version CI License Discord

@pmndrs/sky is a physically based sky, lighting and atmosphere system for three.js on the WebGPU renderer. Attach it to a scene and you get a sky driven by real solar position, correct from ground level to orbit, whose image-based lighting follows every move of the sun.

It is built for frame budgets. The sky is baked into a cube only when something changes, so a frame where nothing moved costs next to nothing; the environment lighting is re-filtered in one compute pass (about 1 ms, several times faster than three's PMREMGenerator), so time-of-day can animate every frame; and the per-pixel work is a handful of texture lookups.

The atmosphere itself builds on Sébastien Hillaire's A Scalable and Production Ready Sky and Atmosphere Rendering Technique (EGSR 2020), and is checked against his reference code and Bruneton's precomputed scattering. Around it you also get aerial-perspective haze for distant geometry (with optional light shafts) or a cheaper sky-coloured height fog, a night sky with pixel-sharp stars and the Milky Way, and stylized looks. Vanilla and React (R3F) entry points, one update() call per frame.

Full docs → · Demo gallery →

Install

npm install @pmndrs/sky
# or
pnpm add @pmndrs/sky

Peer-deps: three (≥0.186.0 — CI tests 0.186.x; the library also runs on r187-dev), and optionally react + @react-three/fiber (≥10.0.0-alpha.4 — earlier 10.x canaries import a WebGL-only class from three/webgpu and fail to load) if you use the React bindings. Requires the WebGPU renderer — see Installation for details.

Quick start

import * as THREE from 'three/webgpu'
import { Sky } from '@pmndrs/sky'

const renderer = new THREE.WebGPURenderer({ antialias: true })
renderer.setSize(innerWidth, innerHeight)
document.body.appendChild(renderer.domElement)
await renderer.init()

const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 2000)
camera.position.set(0, 2, 10)

const sky = new Sky(renderer, {
  preset: 'earth', // 'earth' | 'mars' | 'titan'
  timeOfDay: 14.5, // 0..24
  latitude: 37.7,
  exposure: 40,
})

const scene = new THREE.Scene()
sky.attach(scene) // sets scene.environment + scene.background ({ environment: false } keeps your own IBL)
await sky.compileAsync() // the sky's shaders compile in the background; this has them ready for frame one

renderer.setAnimationLoop(() => {
  sky.update(camera)
  renderer.render(scene, camera)
})

See Your First Sky for the full walkthrough, or jump straight to examples/vanilla to run it locally.

Aerial-perspective haze

import { pass } from 'three/tsl'

const scenePass = pass(scene, camera)
const post = new THREE.RenderPipeline(renderer)
post.outputNode = sky.applyHaze(scenePass.getTextureNode(), {
  scenePass,
  policy: 'auto', // 'auto' | 'ap' | 'raymarch'
})

renderer.setAnimationLoop(() => {
  sky.update(camera)
  sky.updateAerialPerspective()
  post.render()
})

Full explanation of the AP-LUT/raymarch policies and known limitations: Haze guide.

On a tight budget, sky.applyFog(scenePass.getTextureNode(), { scenePass, density, heightFalloff }) is exponential height fog coloured by the baked sky behind the geometry: one texture sample per pixel and no per-frame LUT (no updateAerialPerspective(), and enableAerialPerspective can be false). See Height fog.

React (R3F)

import { Sky } from '@pmndrs/sky/react'
import { AutoHaze } from '@pmndrs/sky/react/auto-haze'

function Scene() {
  return (
    <>
      <Sky preset="earth" timeOfDay={14.5}>
        <AutoHaze />
      </Sky>
      <Mountains />
    </>
  )
}

<AutoHaze> lives in its own sub-export so the useRenderPipeline hook it depends on is only pulled into bundles that actually need it.

<Sky> props mirror the facade: construction options (preset, quality, …) rebuild the instance, the rest (timeOfDay, exposure, sunColor, look / lookTrack, haze knobs, …) go through the setters live. See the React reference for the full props table.

To compose with your own pipeline, skip <AutoHaze /> and grab the instance via useSky():

import { useSky } from '@pmndrs/sky/react'
import { useRenderPipeline } from '@react-three/fiber/webgpu'

function CustomPipeline() {
  const sky = useSky()
  useRenderPipeline(({ renderPipeline, passes }) => {
    if (!sky) return
    renderPipeline.outputNode = sky.applyHaze(passes.scenePass.getTextureNode(), {
      scenePass: passes.scenePass,
      policy: 'auto',
    })
  })
  return null
}

Docs

A condensed method table for the Sky facade:

Method Description
setTimeOfDay(hours) NOAA solar position; combined with latitude + dayOfYear
setLatitude(deg) / setDayOfYear(day) Solar position inputs
setSunDirection({ elevation, azimuth }) Direct override; azimuth is a compass azimuth, clockwise from north (90 = east, on your right facing north)
setNorth('+X' | '-X' | '+Z' | '-Z' | degrees) Where geographic north points: an axis, or a heading clockwise from +Z seen from above
setExposure(n) Sky luminance scale (default 40)
setSunDisc(boolean | { angularDiameter }) Disc visibility + size in radians
setSunColor(color) Colour of the sun as a light ('neutral', 'bruneton', hex, Color, [r,g,b])
setLook(look) / setLookTrack(track) Stylized colour ramp over the physical sky, fixed or following sun elevation
setTurbidity(n) Mie scattering scalar (1 = Earth)
setGroundAlbedo(n | Vector3) Multi-scatter LUT input
setMirrorBelowHorizon(boolean) Bake a Y-mirrored sky on the cube's lower hemisphere instead of lit-ground albedo (clean sky HDRI for reflective-floor scenes)
setPreset('earth' | 'mars' | 'titan') Swap atmosphere defaults
setAtmosphere(partial) Direct atmosphere-params override
setHazeStrength(n) / setHazePolicy(p) / setHazeAltitudeBlend({startKm, endKm}) Live haze knobs
setHazeShadows(opts) Live light-shaft knobs (shadowed haze)
update(camera, { planetCenter? }) Per-frame; planet-frame altitude when planetCenter is set
updateAerialPerspective() Per-frame; required when applyHaze is wired
flushEnvironment() Finish any pending IBL refresh now (screenshots, hard cuts)
applyHaze(sceneColorNode, options) Returns a vec4 TSL output node; pass shadows: { light } for light shafts
applyFog(sceneColorNode, options) / setFog(opts) Sky-coloured height fog (no per-frame LUT) and its live knobs
createSun(opts) / createGround(opts) / createGroundedSkybox(opts) / createMoon(opts) Factories for the optional helper objects
enableStars(opts) / disableStars() Night sky: star sprites + Milky Way (async on first enable)
attach(scene, { background?, environment? }) / detach() / dispose() Lifecycle; attach claims both scene slots unless told otherwise

The pmrem constructor option picks the IBL prefilter: by default environmentTexture is re-filtered on every sky change with WebGPU compute (~1 ms), falling back to three's throttled PMREMGenerator on the WebGL2 backend.

See the full Sky API reference for every option and setter.

GroundedSkybox (optional)

A ground-projected skybox mesh — the lower hemisphere of the cube is reprojected onto a flat disc at world y=0, so the cube content acts as a "floor" without needing an explicit ground plane. Pass reflective: true for a mirror-floor / wet-pavement look (disc samples the cube via reflect(viewDir, +Y)). Pair with sky.setMirrorBelowHorizon(true) if you want PBR materials' downward IBL to match the visible floor.

const skybox = sky.createGroundedSkybox({ height: 4, radius: 200, reflective: false })
scene.add(skybox)

// per frame, so the disc stays anchored under the camera:
skybox.followCamera(camera)

Demo gallery

Every example lives in examples/vanilla and is published at sky.docs.pmnd.rs/examples — baked sky, aerial-perspective haze, planet-scale ground-to-orbit, individual LUT debug views, and the numbered scratch demos used during development.

Status

Baked sky, aerial-perspective haze with light shafts, sky-coloured height fog, planet-scale (ground→orbit) rendering, the night sky, and stylized looks are all functional. Volumetric clouds are out of scope.

Contributing

git clone https://github.com/pmndrs/sky.git
cd sky
pnpm install
pnpm run ci          # build + typecheck + lint + test + format check
pnpm example:vanilla  # Vite dev server on :5173 for examples/vanilla

The library itself builds via unbuild (pnpm dev runs unbuild --stub for library dev — that's not the examples server). See CLAUDE.md for the architecture write-up and a running list of hard-won implementation gotchas, and ROADMAP.md for current status and open work.

Changes land through pull requests, which are squash-merged. PR titles must be Conventional Commits, because they become the commit on main that the changelog and version bump are generated from: feat(haze): light shafts, fix(react): …, docs: …, examples: …. feat bumps the minor version (pre-1.0, breaking changes do too, marked feat!:), fix/perf bump the patch. A check on every PR enforces the format.

Changelog

See CHANGELOG.md. Releases are cut by release-please from merged pull requests; RELEASING.md has the details.

License

MIT © Dennis Smolek

About

Full WebGPU sky system based on the Unreal Engine sky by Sébastien Hillaire

Resources

Stars

9 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages