Transmission

A glass cube refracts the scene behind it in screen space: the frame is rendered, blurred into a pyramid, and read back through Snell refraction, chromatic dispersion and a Fresnel-weighted environment reflection.

Open fullscreen
import type { Draw, Effect, Frame, Gpu, Geometry, Surface, Target } from 'vgpu';
import type { Texture } from 'vgpu/core';
import { box, plane } from 'vgpu/scene';
 
import type { BrowserRendererOptions, ExampleRenderer, RenderSize, ThumbnailOptions } from '../../lib/example-renderer';
import { cameraView, DEFAULT_PITCH, DEFAULT_YAW, modelMatrix, translationMatrix, type CameraView } from './camera';
import { installOrbitInput } from './pointer-input';
import { DEFAULT_TRANSMISSION_CONTROLS, type TransmissionControls } from './types';
import skyWgsl from './sky.wgsl';
import blurWgsl from './blur.wgsl';
import backgroundWgsl from './scene-background.wgsl';
import floorWgsl from './floor.wgsl';
import backfaceWgsl from './backface-normal.wgsl';
import glassWgsl from './glass.wgsl';
import presentWgsl from './present.wgsl';
import { draw, effect, frame, geometry, sampler, surface, target } from "vgpu";
 
type Output = Surface | Target;
 
const HDR_FORMAT: GPUTextureFormat = 'rgba16float';
/** 2:1 is the equirectangular aspect: 360° of yaw by 180° of pitch. */
const ENV_SIZE: readonly [number, number] = [2048, 1024];
/** Levels of the prefiltered environment pyramid: 2048×1024 down to 16×8. */
const ENV_LEVELS = 8;
/** Levels of the screen-space scene pyramid, rebuilt every frame; roughness reads them. */
const SCENE_LEVELS = 8;
/** Gaussian radius in destination texels; ~1 keeps each level one octave blurrier. */
const BLUR_RADIUS = 1.15;
const CUBE_SIZE = 1.3;
const FLOOR_SIZE = 90;
const FLOOR_HEIGHT = -1.05;
const EXPOSURE = 0.95;
/** Angle covered by one texel of level 0, the unit every environment LOD is measured against. */
const TEXEL_ANGLE = (2 * Math.PI) / ENV_SIZE[0];
 
const SKY = {
  sun_direction: [-0.724, 0.09, -0.684],
  sun_angular_size: 0.018,
  sun_color: [1.0, 0.88, 0.72],
  sun_intensity: 26,
  zenith_color: [0.05, 0.15, 0.44],
  cloud_coverage: 0.56,
  horizon_color: [0.36, 0.48, 0.74],
  cloud_scale: 0.75,
  ground_color: [0.05, 0.05, 0.056],
  ground_scale: 4.6,
} as const;
 
const FLOOR = {
  checker_scale: 0.85,
  /** Half-angle of the floor's reflection cone, in radians: a wide, satin sky reflection. */
  reflection_roughness: 0.045,
  horizon_color: SKY.horizon_color,
  texel_angle: TEXEL_ANGLE,
  base_color: [0.052, 0.055, 0.062],
  fade_distance: 26,
  env_size: ENV_SIZE,
  sun_direction: SKY.sun_direction,
} as const;
 
const GLASS = {
  /** Ray length inside the solid in `simple` mode; `double` measures the real one. */
  thickness: 0.85,
  /** Beer-Lambert absorption per world unit: a faint bottle-glass tint. */
  absorption: [0.3, 0.1, 0.16],
  env_size: ENV_SIZE,
  texel_angle: TEXEL_ANGLE,
  /**
   * IOR distance between the red and the blue end of the spectral sweep.
   *
   * Wider than a real soda-lime glass (whose whole visible spread is ~0.01) because the
   * cube is small and the rainbow has to survive a 1280px thumbnail; the samples in
   * between keep it continuous instead of turning the extra width into hard bands.
   */
  dispersion_spread: 0.09,
} as const;
 
interface LevelTargets {
  readonly size: readonly [number, number];
  readonly horizontal: Target;
  readonly vertical: Target;
}
 
interface Targets {
  readonly size: readonly [number, number];
  /** Scene colour + depth: sky, floor, and later the glass composited on top. */
  readonly hdr: Target;
  /** Back-face normal (rgb) and camera distance (a) of the cube. */
  readonly backface: Target;
  /** Mip pyramid of the scene, the source every refraction reads. */
  readonly pyramid: Texture;
  readonly levels: number;
  readonly chain: readonly LevelTargets[];
}
 
interface BlurPair {
  readonly horizontal: Effect;
  readonly vertical: Effect;
}
 
interface Scene {
  readonly env: Texture;
  readonly envSampler: GPUSampler;
  /** Trilinear: roughness lands on a fractional LOD of the scene pyramid. */
  readonly pyramidSampler: GPUSampler;
  readonly screenSampler: GPUSampler;
  readonly cubeGeometry: Geometry;
  readonly floorGeometry: Geometry;
  readonly background: Draw;
  readonly floor: Draw;
  readonly backface: Draw;
  readonly glass: Draw;
  readonly present: Effect;
  /** One instance per pass: two effects sharing a frame would alias their uniforms. */
  readonly blurs: readonly BlurPair[];
  targets: Targets;
}
 
export function createRenderer(options: BrowserRendererOptions<TransmissionControls>): ExampleRenderer<TransmissionControls> {
  let disposed = false;
  let reportedError = false;
  let controls: TransmissionControls = normalizeControls(options.initialControls ?? DEFAULT_TRANSMISSION_CONTROLS);
  let gpu: Gpu | undefined;
  let canvasSurface: Surface | undefined;
  let scene: Scene | undefined;
  let input: ReturnType<typeof installOrbitInput> | undefined;
  let animationFrame = 0;
  let previous = 0;
  let observer: ResizeObserver | undefined;
  let unsubscribeResize: (() => void) | undefined;
  let resizeFrame = 0;
  let pendingSize: RenderSize | undefined;
  let lastDpr = typeof window === 'undefined' ? 1 : window.devicePixelRatio;
  let sawInitialResize = false;
 
  const onSurfaceResize = () => {
    // The surface replays its current size on subscribe; the targets were just built at
    // that size, so only later reports are real resizes.
    if (!sawInitialResize) { sawInitialResize = true; return; }
    if (disposed || !gpu || !scene || !canvasSurface) return;
    try {
      const next = createTargets(gpu, canvasSurface.size, 'transmission-live');
      destroyTargets(scene.targets);
      scene.targets = next;
      bindTargets(scene);
    } catch (error) {
      handleFailure(error);
    }
  };
 
  const applyResize = () => {
    resizeFrame = 0;
    const size = pendingSize;
    pendingSize = undefined;
    if (disposed || !size || !canvasSurface) return;
    try {
      canvasSurface.resize([
        Math.max(1, Math.round(size.width * size.dpr)),
        Math.max(1, Math.round(size.height * size.dpr)),
      ]);
    } catch (error) {
      handleFailure(error);
    }
  };
  const resize = (size: RenderSize) => {
    if (disposed || size.width <= 0 || size.height <= 0) return;
    pendingSize = size;
    if (!resizeFrame) resizeFrame = requestAnimationFrame(applyResize);
  };
  const measure = () => {
    const rect = options.canvas.getBoundingClientRect();
    resize({
      width: rect.width,
      height: rect.height,
      dpr: Math.min(2, Math.max(1, window.devicePixelRatio || 1)),
    });
  };
  const onWindowResize = () => {
    if (window.devicePixelRatio === lastDpr) return;
    lastDpr = window.devicePixelRatio;
    measure();
  };
 
  const setControls = (next: Readonly<TransmissionControls>) => {
    if (disposed) return;
    controls = normalizeControls(next);
  };
 
  // One rendered frame is three submits — scene + blur chain, the mip copies, then glass
  // and present — so the loop is a plain rAF instead of `frameLoop`, which owns the
  // whole frame and rejects the nested `frame(gpu)` calls the copies sit between.
  const tick = (now: number) => {
    animationFrame = 0;
    if (disposed) return;
    if (!document.hidden && gpu && canvasSurface && scene && input) {
      try {
        input.advance((now - previous) / 1000);
        const view = cameraView(input.yaw, input.pitch, aspectOf(canvasSurface), input.radius);
        renderScene(gpu, scene, canvasSurface, view, controls);
      } catch (error) {
        handleFailure(error);
        return;
      }
    }
    // Always reset the clock while hidden so visibility changes never catch up at once.
    previous = now;
    animationFrame = requestAnimationFrame(tick);
  };
 
  function dispose(): void {
    if (disposed) return;
    disposed = true;
    if (animationFrame) cancelAnimationFrame(animationFrame);
    animationFrame = 0;
    if (resizeFrame) cancelAnimationFrame(resizeFrame);
    resizeFrame = 0;
    pendingSize = undefined;
    observer?.disconnect();
    observer = undefined;
    if (typeof window !== 'undefined') window.removeEventListener('resize', onWindowResize);
    unsubscribeResize?.();
    unsubscribeResize = undefined;
    input?.dispose();
    input = undefined;
    if (scene) destroyScene(scene);
    scene = undefined;
    canvasSurface?.dispose();
    canvasSurface = undefined;
    gpu?.dispose();
    gpu = undefined;
  }
 
  function handleFailure(error: unknown): void {
    if (disposed) return;
    if (!reportedError) {
      reportedError = true;
      try { options.onError?.(error); } catch { /* error reporting must not block teardown */ }
    }
    dispose();
  }
 
  const initialize = async () => {
    const { init } = await import('vgpu');
    if (disposed) return;
    const nextGpu = await init();
    if (disposed) { nextGpu.dispose(); return; }
    gpu = nextGpu;
    canvasSurface = surface(gpu, options.canvas, { dpr: [1, 2] });
    scene = await createScene(gpu, canvasSurface, 'transmission-live');
    if (disposed) return;
    input = installOrbitInput(options.canvas);
    unsubscribeResize = canvasSurface.onResize(onSurfaceResize);
    observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);
    observer?.observe(options.canvas);
    window.addEventListener('resize', onWindowResize);
    measure();
    previous = performance.now();
    animationFrame = requestAnimationFrame(tick);
  };
 
  const ready = initialize().catch((error: unknown) => {
    if (disposed) return;
    handleFailure(error);
    throw error;
  });
 
  return { ready, setControls, invalidate() {}, resize, dispose };
}
 
export async function renderThumbnail(gpu: Gpu, output: Target, opts: ThumbnailOptions = {}): Promise<void> {
  const scene = await createScene(gpu, output, 'transmission-thumb');
  try {
    // Fixed camera and controls: nothing here reads the clock, so every run of the
    // headless renderer produces the same pixels.
    const view = cameraView(DEFAULT_YAW, DEFAULT_PITCH, aspectOf(output));
    for (let i = 0; i < Math.max(1, opts.warmupFrames ?? 3); i++) {
      renderScene(gpu, scene, output, view, DEFAULT_TRANSMISSION_CONTROLS);
    }
    await gpu.gpu.queue.onSubmittedWorkDone();
    await gpu.settled();
  } finally {
    destroyScene(scene);
  }
}
 
async function createScene(gpu: Gpu, output: Output, label: string): Promise<Scene> {
  const envSampler = sampler(gpu, {
    minFilter: 'linear',
    magFilter: 'linear',
    // Trilinear: roughness lands on a fractional LOD, so neighbouring levels must blend.
    mipmapFilter: 'linear',
    // u wraps the horizon; v must clamp so the poles never bleed across.
    addressModeU: 'repeat',
    addressModeV: 'clamp-to-edge',
  });
  const pyramidSampler = sampler(gpu, {
    minFilter: 'linear',
    magFilter: 'linear',
    mipmapFilter: 'linear',
    // Screen space: a refracted ray that grazes the border must not wrap around.
    addressModeU: 'clamp-to-edge',
    addressModeV: 'clamp-to-edge',
  });
  const screenSampler = sampler(gpu, { minFilter: 'linear', magFilter: 'linear' });
 
  const env = await bakeEnvironment(gpu, envSampler, label);
  const targets = createTargets(gpu, output.size, label);
 
  const cubeGeometry = geometry(gpu, box({ size: CUBE_SIZE }));
  const floorGeometry = geometry(gpu, plane({ width: FLOOR_SIZE, height: FLOOR_SIZE, widthSegments: 1, heightSegments: 1 }));
 
  // The sky covers the frame from the far plane without touching depth, so the floor and
  // the cube behind it still resolve against each other normally.
  const background = draw(gpu, {
    shader: backgroundWgsl,
    vertices: 3,
    depth: { write: false, compare: 'always' },
    label: `${label}-background`,
  });
  background.set({ env_tex: env, env_samp: envSampler });
 
  const floor = draw(gpu, { shader: floorWgsl, geometry: floorGeometry, cull: 'none', label: `${label}-floor` });
  floor.set({ env_tex: env, env_samp: envSampler });
 
  const backface = draw(gpu, { shader: backfaceWgsl, geometry: cubeGeometry, cull: 'front', label: `${label}-backface` });
  const glass = draw(gpu, { shader: glassWgsl, geometry: cubeGeometry, cull: 'back', label: `${label}-glass` });
  glass.set({ env_tex: env, env_samp: envSampler });
 
  const present = effect(gpu, presentWgsl, { label: `${label}-present` });
  present.set({ present: { exposure: EXPOSURE } });
 
  const blurs: BlurPair[] = [];
  for (let level = 1; level < SCENE_LEVELS; level++) {
    blurs.push({
      horizontal: effect(gpu, blurWgsl, { label: `${label}-scene-blur-h${level}` }),
      vertical: effect(gpu, blurWgsl, { label: `${label}-scene-blur-v${level}` }),
    });
  }
 
  const scene: Scene = {
    env, envSampler, pyramidSampler, screenSampler,
    cubeGeometry, floorGeometry,
    background, floor, backface, glass, present, blurs,
    targets,
  };
  bindTargets(scene);
 
  await Promise.all([
    background.compile(targets.hdr),
    floor.compile(targets.hdr),
    glass.compile(targets.hdr),
    backface.compile(targets.backface),
    present.compile({ colors: [output.format] }),
    ...blurs.flatMap((pair) => [pair.horizontal.compile({ colors: [HDR_FORMAT] }), pair.vertical.compile({ colors: [HDR_FORMAT] })]),
  ]);
  return scene;
}
 
/**
 * Fills the equirectangular environment map and its prefiltered pyramid, once, at startup.
 *
 * Level 0 is the sky itself; every level below is the previous one run through a
 * separable Gaussian while the resolution halves, so level L carries roughly 2^L texels
 * of angular blur. The glass reads its reflections out of it with a single
 * `textureSampleLevel` instead of tracing a cone of taps per pixel.
 */
async function bakeEnvironment(gpu: Gpu, samplerState: GPUSampler, label: string): Promise<Texture> {
  const env = gpu.device.createTexture({
    size: [...ENV_SIZE],
    format: HDR_FORMAT,
    mipLevelCount: ENV_LEVELS,
    usage: ['texture_binding', 'copy_dst'],
    label: `${label}-env`,
  });
 
  const sky = effect(gpu, skyWgsl, { label: `${label}-sky` });
  sky.set({ sky: SKY });
  const blur = effect(gpu, blurWgsl, { label: `${label}-env-blur` });
 
  let source = target(gpu, { size: [...ENV_SIZE], format: HDR_FORMAT, label: `${label}-env-level0` });
  await Promise.all([sky.compile(source), blur.compile(source)]);
  frame(gpu, (currentFrame) => currentFrame.pass({ target: source }, (pass) => pass.draw(sky)));
  copyIntoLevel(gpu, source, env, 0, `${label}-env`);
 
  for (let level = 1; level < ENV_LEVELS; level++) {
    const size: [number, number] = [
      Math.max(1, ENV_SIZE[0] >> level),
      Math.max(1, ENV_SIZE[1] >> level),
    ];
    const horizontal = target(gpu, { size, format: HDR_FORMAT, label: `${label}-env-blur-h${level}` });
    const vertical = target(gpu, { size, format: HDR_FORMAT, label: `${label}-env-level${level}` });
    const texel: [number, number] = [1 / size[0], 1 / size[1]];
 
    // One frame per pass so each draw picks up its own bindings. This runs once, at
    // startup — the per-frame scene pyramid below never touches it.
    blur.set({ src: source, src_samp: samplerState, blur: { texel, direction: [1, 0], radius: BLUR_RADIUS, equirect_compensation: 1 } });
    frame(gpu, (currentFrame) => currentFrame.pass({ target: horizontal }, (pass) => pass.draw(blur)));
    blur.set({ src: horizontal, src_samp: samplerState, blur: { texel, direction: [0, 1], radius: BLUR_RADIUS, equirect_compensation: 0 } });
    frame(gpu, (currentFrame) => currentFrame.pass({ target: vertical }, (pass) => pass.draw(blur)));
 
    copyIntoLevel(gpu, vertical, env, level, `${label}-env`);
    destroyTarget(horizontal);
    destroyTarget(source);
    source = vertical;
  }
  destroyTarget(source);
 
  return env;
}
 
function createTargets(gpu: Gpu, size: readonly [number, number], label: string): Targets {
  const full: readonly [number, number] = [Math.max(1, Math.floor(size[0])), Math.max(1, Math.floor(size[1]))];
  // A texture cannot hold more mips than its largest side has halvings.
  const levels = Math.max(1, Math.min(SCENE_LEVELS, Math.floor(Math.log2(Math.max(full[0], full[1]))) + 1));
  const created: Target[] = [];
  try {
    const hdr = target(gpu, { size: full, format: HDR_FORMAT, depth: true, label: `${label}-scene` });
    created.push(hdr);
    const backface = target(gpu, { size: full, format: HDR_FORMAT, label: `${label}-backface` });
    created.push(backface);
    const pyramid = gpu.device.createTexture({
      size: [...full],
      format: HDR_FORMAT,
      mipLevelCount: levels,
      usage: ['texture_binding', 'copy_dst'],
      label: `${label}-scene-pyramid`,
    });
    const chain: LevelTargets[] = [];
    for (let level = 1; level < levels; level++) {
      const levelSize: [number, number] = [Math.max(1, full[0] >> level), Math.max(1, full[1] >> level)];
      const horizontal = target(gpu, { size: levelSize, format: HDR_FORMAT, label: `${label}-scene-blur-h${level}` });
      created.push(horizontal);
      const vertical = target(gpu, { size: levelSize, format: HDR_FORMAT, label: `${label}-scene-level${level}` });
      created.push(vertical);
      chain.push({ size: levelSize, horizontal, vertical });
    }
    return { size: full, hdr, backface, pyramid, levels, chain };
  } catch (error) {
    for (const colorTarget of created) destroyTarget(colorTarget);
    throw error;
  }
}
 
/** Points every consumer at the current targets; re-run after each resize. */
function bindTargets(scene: Scene): void {
  const { targets } = scene;
  scene.glass.set({
    scene_tex: targets.pyramid,
    scene_samp: scene.pyramidSampler,
    backface_tex: targets.backface,
    backface_samp: scene.screenSampler,
  });
  scene.present.set({ color_tex: targets.hdr, color_samp: scene.screenSampler });
 
  for (let index = 0; index < scene.blurs.length; index++) {
    const level = targets.chain[index];
    if (!level) break;
    const source = index === 0 ? targets.hdr : targets.chain[index - 1].vertical;
    const texel: [number, number] = [1 / level.size[0], 1 / level.size[1]];
    // Screen space has no poles to stretch, so the equirect compensation stays off.
    scene.blurs[index].horizontal.set({ src: source, src_samp: scene.screenSampler, blur: { texel, direction: [1, 0], radius: BLUR_RADIUS, equirect_compensation: 0 } });
    scene.blurs[index].vertical.set({ src: level.horizontal, src_samp: scene.screenSampler, blur: { texel, direction: [0, 1], radius: BLUR_RADIUS, equirect_compensation: 0 } });
  }
}
 
/**
 * One rendered frame.
 *
 * The order is the whole technique: the scene has to exist and be blurred before the
 * glass can look through it, and nothing may sample the texture it is writing. Pass A
 * fills colour and depth, the pyramid is built from that colour into a separate texture,
 * and only then is the cube drawn back into the scene target — depth-tested against the
 * floor, sampling the pyramid, never the target under its own pen.
 */
function renderScene(gpu: Gpu, scene: Scene, output: Output, view: CameraView, controls: TransmissionControls): void {
  const { targets } = scene;
  const model = modelMatrix();
  const doubleRefraction = controls.refraction === 'double';
 
  scene.background.set({
    scene_camera: {
      position: view.position,
      tan_half_fov: view.tanHalfFov,
      forward: view.forward,
      aspect: view.aspect,
      right: view.right,
      texel_angle: TEXEL_ANGLE,
      up: view.up,
      intensity: 1,
      env_size: ENV_SIZE,
    },
  });
  scene.floor.set({
    floor_uniforms: {
      ...FLOOR,
      view_projection: view.camera.viewProjection,
      model: translationMatrix(0, FLOOR_HEIGHT, 0),
      camera_position: view.position,
    },
  });
  scene.backface.set({
    backface: {
      view_projection: view.camera.viewProjection,
      model,
      camera_position: view.position,
    },
  });
  scene.glass.set({
    glass: {
      ...GLASS,
      view_projection: view.camera.viewProjection,
      model,
      camera_position: view.position,
      ior: controls.ior,
      roughness: controls.roughness,
      dispersion: controls.dispersion ? 1 : 0,
      refraction_mode: doubleRefraction ? 1 : 0,
      scene_levels: targets.levels,
    },
  });
 
  frame(gpu, (currentFrame) => {
    currentFrame.pass({ target: targets.hdr, clear: [0, 0, 0, 1] }, (pass) => {
      pass.draw(scene.background);
      pass.draw(scene.floor);
    });
    // Every level halves the resolution and doubles the blur, so a roughness of 1 reads a
    // scene that is 128 pixels wide: frosted glass for the price of one fetch.
    for (let index = 0; index < targets.chain.length; index++) {
      const level = targets.chain[index];
      currentFrame.pass({ target: level.horizontal }, (pass) => pass.draw(scene.blurs[index].horizontal));
      currentFrame.pass({ target: level.vertical }, (pass) => pass.draw(scene.blurs[index].vertical));
    }
  });
 
  copyPyramid(gpu, targets);
 
  frame(gpu, (currentFrame) => {
    // The mini-pass only runs where it is read: `simple` never touches the back face.
    if (doubleRefraction) {
      currentFrame.pass({ target: targets.backface, clear: [0, 0, 0, 0] }, (pass) => pass.draw(scene.backface));
    }
    // `clear: false` keeps the sky, the floor and their depth: the cube is composited
    // into the scene it just refracted, and the floor can occlude it.
    currentFrame.pass({ target: targets.hdr, clear: false }, (pass) => pass.draw(scene.glass));
    currentFrame.pass({ target: output }, (pass) => pass.draw(scene.present));
  });
}
 
/**
 * Moves the blurred levels into the pyramid texture, in one submit between the two frames.
 *
 * A render pass cannot write into a mip level of another texture, and the copies must
 * land after every blur pass and before the glass pass reads them, which is exactly what
 * a separate command buffer submitted in between guarantees.
 */
function copyPyramid(gpu: Gpu, targets: Targets): void {
  const encoder = gpu.gpu.createCommandEncoder({ label: 'transmission-scene-pyramid' });
  encoder.copyTextureToTexture(
    { texture: targets.hdr.color.gpu },
    { texture: targets.pyramid.gpu, mipLevel: 0 },
    [targets.size[0], targets.size[1], 1],
  );
  for (let index = 0; index < targets.chain.length; index++) {
    const level = targets.chain[index];
    encoder.copyTextureToTexture(
      { texture: level.vertical.color.gpu },
      { texture: targets.pyramid.gpu, mipLevel: index + 1 },
      [level.size[0], level.size[1], 1],
    );
  }
  gpu.gpu.queue.submit([encoder.finish()]);
}
 
/** A render pass cannot write into a mip level of another texture, so levels are copied in. */
function copyIntoLevel(gpu: Gpu, source: Target, texture: Texture, level: number, label: string): void {
  const encoder = gpu.gpu.createCommandEncoder({ label: `${label}-copy-level${level}` });
  encoder.copyTextureToTexture(
    { texture: source.color.gpu },
    { texture: texture.gpu, mipLevel: level },
    [source.size[0], source.size[1], 1],
  );
  gpu.gpu.queue.submit([encoder.finish()]);
}
 
function normalizeControls(controls: Readonly<TransmissionControls>): TransmissionControls {
  return {
    ior: Math.max(1, Math.min(2.4, Number.isFinite(controls.ior) ? controls.ior : DEFAULT_TRANSMISSION_CONTROLS.ior)),
    roughness: Math.max(0, Math.min(1, Number.isFinite(controls.roughness) ? controls.roughness : DEFAULT_TRANSMISSION_CONTROLS.roughness)),
    dispersion: Boolean(controls.dispersion),
    refraction: controls.refraction === 'double' ? 'double' : 'simple',
  };
}
 
function aspectOf(output: Output): number {
  return output.size[0] / Math.max(1, output.size[1]);
}
 
function destroyScene(scene: Scene): void {
  destroyTargets(scene.targets);
  scene.cubeGeometry.destroy();
  scene.floorGeometry.destroy();
  scene.env.destroy();
}
 
function destroyTargets(targets: Targets): void {
  destroyTarget(targets.hdr);
  destroyTarget(targets.backface);
  targets.pyramid.destroy();
  for (const level of targets.chain) {
    destroyTarget(level.horizontal);
    destroyTarget(level.vertical);
  }
}
 
function destroyTarget(colorTarget: Target): void {
  (colorTarget as Target & { destroy?: () => void }).destroy?.();
}