All entries

Class-based Capsule Corp. spaceship on Namek

Source

"use typeshade";

/* @example
{
  "title": "Class-based Capsule Corp. spaceship on Namek",
  "blurb": "A class-based conversion of a Dragon Ball Z Capsule Corp. spaceship scene: the ship, landing gear, trees, terrain, lettering, materials, cel shading and camera are organized as TypeShade classes around a shared SDF scene.",
  "renderable": true
}
*/


// =======================================================================================================
// Capsule Corp. Spaceship from Dragon Ball Z, landed on Namek
//
// Class-based TypeShade conversion of the original ShaderToy shader.
// The rendering logic is kept intact while the major shader systems are
// represented as ordinary TypeScript classes.
// =======================================================================================================

class Uniforms {
  time: f32;
  resolution: vec2;
  mouse: vec2;
}

declare const u: uniform<Uniforms>;

// ---------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------

const AA: i32 = 1;

const PI = 3.14159265;
const TAU = 6.28318531;

const GROUND = -1.04;
const BAND_TOP = 0.08;
const BAND_BOT = -0.52;

const WIN_N = 12.0;
const WIN_Y = -0.22;
const WIN_A = 0.10;
const WIN_BULGE = 0.06;

const RING_Y = 0.71;
const RING_H = 0.04;

const DOOR_HW = 0.17;
const DOOR_Y0 = -0.60;
const DOOR_Y1 = 0.21;
const DOOR_T = 0.005;

const LEG_LON = 0.7854;
const FLAP_Y = -0.60;
const FLAP_LEN = 0.36;
const FLAP_HW = 0.125;
const FLAP_TIP_HW = 0.08;
const FLAP_T = 0.03;
const FLAP_OPEN = 2.2;
const BAY_DEPTH = 0.03;

const LEG_R = 0.99;
const SHOCK_Y = GROUND + 0.12;
const BELLY_R = 0.26;
const BELLY_Y = -sqrt(1.0 - BELLY_R * BELLY_R);
const BELLY_EXT = 0.06;
const SHAFT_R = 0.10;

const CAPS_LON = 0.08;
const CAPS_LAT = 0.547;
const CAPS_H = 0.25;

const CORP_LON = 0.02;
const CORP_LAT = 0.284;
const CORP_H = 0.10;

const ADV = 0.78;

const COAST_R = 10.0;
const MESA_R = 18.0;

const SW = 0.085;

const SHEEN_R = 0.99;
const HATCH_W = 0.02;
const HATCH_L = 0.09;
const HATCH_D = 0.09;

const SCUFFS = 10.0;

const DIRT_GRAIN = vec2(0.7, 0.0);
const DIRT_TILT = 0.3;

// Material palette

const INK = vec3(0.05, 0.07, 0.09);
const HULL = vec3(0.92, 0.95, 0.91);
const BLACK = vec3(0.07, 0.11, 0.14);
const BLACK_HI = vec3(0.47, 0.68, 0.68);
const METAL = vec3(0.74, 0.76, 0.78);
const GLASS_LO = vec3(0.36, 0.70, 0.80);
const GLASS_HI = vec3(0.06, 0.33, 0.50);
const SHADE = vec3(0.64, 0.76, 0.86);

const SKY_TOP = vec3(0.30, 0.67, 0.37);
const SKY_MID = vec3(0.58, 0.78, 0.50);
const SKY_HZ = vec3(0.74, 0.87, 0.70);
const CLOUD = vec3(0.84, 0.93, 0.77);
const HAZE = vec3(0.62, 0.80, 0.74);
const GRASS = vec3(0.20, 0.57, 0.58);
const GRASS_DK = vec3(0.15, 0.45, 0.50);
const SAND = vec3(0.86, 0.80, 0.62);
const SAND_RIM = vec3(0.80, 0.50, 0.38);
const DIRT_DK = vec3(0.60, 0.43, 0.28);
const SEA = vec3(0.435, 0.663, 0.494);
const SEA_SHOAL = vec3(0.55, 0.77, 0.62);
const SEA_HI = vec3(0.80, 0.91, 0.80);
const MESA_TOP = vec3(0.30, 0.62, 0.55);
const CLIFF_A = vec3(0.86, 0.58, 0.48);
const CLIFF_B = vec3(0.95, 0.80, 0.66);
const CLIFF_C = vec3(0.66, 0.38, 0.36);
const LEAF = vec3(0.13, 0.48, 0.60);
const LEAF_HI = vec3(0.38, 0.72, 0.78);
const TRUNK = vec3(0.86, 0.88, 0.78);

const LIG = normalize(vec3(-0.59, 0.36, 0.73));

const CROSS: array<vec2, 4> = [
  vec2(-1.0, 0.0),
  vec2(1.0, 0.0),
  vec2(0.0, -1.0),
  vec2(0.0, 1.0)
];

const TET: array<vec3, 4> = [
  vec3(1.0, -1.0, -1.0),
  vec3(-1.0, -1.0, 1.0),
  vec3(-1.0, 1.0, -1.0),
  vec3(1.0)
];

// ---------------------------------------------------------------------
// Utility result types
// ---------------------------------------------------------------------

class PolarResult {
  point: vec3;
  index: f32;

  constructor(point: vec3, index: f32) {
    this.point = point;
    this.index = index;
  }
}

class MarchResult {
  hit: vec2;
  edge: f32;
  winEdge: f32;
  tEdge: f32;

  constructor(hit: vec2, edge: f32, winEdge: f32, tEdge: f32) {
    this.hit = hit;
    this.edge = edge;
    this.winEdge = winEdge;
    this.tEdge = tEdge;
  }
}

// ---------------------------------------------------------------------
// Noise
// ---------------------------------------------------------------------

class Noise {
  static hash12(p: vec2): f32 {
    let p3 = fract(vec3(p.xyx) * 0.1031);
    p3 += dot(p3, p3.yzx + 33.33);
    return fract((p3.x + p3.y) * p3.z);
  }

  static value(p: vec2): f32 {
    const i = floor(p);
    const f = fract(p);
    const q = f * f * (3.0 - 2.0 * f);

    return mix(
      mix(
        Noise.hash12(i),
        Noise.hash12(i + vec2(1.0, 0.0)),
        q.x
      ),
      mix(
        Noise.hash12(i + vec2(0.0, 1.0)),
        Noise.hash12(i + vec2(1.0, 1.0)),
        q.x
      ),
      q.y
    );
  }

  static fbm(p: vec2): f32 {
    const rotation = mat2(1.6, 1.2, -1.2, 1.6);
    let q = p;
    let sum = 0.0;
    let amplitude = 0.5;

    for (let i = 0; i < 4; i++) {
      sum += amplitude * Noise.value(q);
      q = rotation * q;
      amplitude *= 0.5;
    }

    return sum / 0.9375;
  }

  static mod(x: f32, y: f32): f32 {
    return x - y * floor(x / y);
  }

  static wrapX(p: vec2, n: f32): f32 {
    const i = floor(p);
    const f = fract(p);
    const q = f * f * (3.0 - 2.0 * f);
    const i0 = Noise.mod(i.x, n);
    const i1 = Noise.mod(i.x + 1.0, n);

    return mix(
      mix(
        Noise.hash12(vec2(i0, i.y)),
        Noise.hash12(vec2(i1, i.y)),
        q.x
      ),
      mix(
        Noise.hash12(vec2(i0, i.y + 1.0)),
        Noise.hash12(vec2(i1, i.y + 1.0)),
        q.x
      ),
      q.y
    );
  }

  static fbmWrapX(p: vec2, n: f32): f32 {
    let q = p;
    let period = n;
    let sum = 0.0;
    let amplitude = 0.5;

    for (let i = 0; i < 4; i++) {
      sum += amplitude * Noise.wrapX(q, period);
      q = 2.0 * q + vec2(0.0, 5.3);
      period *= 2.0;
      amplitude *= 0.5;
    }

    return sum / 0.9375;
  }
}

// ---------------------------------------------------------------------
// SDF primitives
// ---------------------------------------------------------------------

function inverseMat2(a: f32, b: f32, c: f32, d: f32): mat2 {
  const determinant = a * d - b * c;

  return mat2(
    d / determinant,
    -b / determinant,
    -c / determinant,
    a / determinant
  );
}

class SDF {
  static union(a: vec2, b: vec2): vec2 {
    return a.x < b.x ? a : b;
  }

  static segment(p: vec2, a: vec2, b: vec2): f32 {
    const pa = p - a;
    const ba = b - a;

    return length(pa - ba * clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0));
  }

  static box(p: vec3, b: vec3): f32 {
    const q = abs(p) - b;
    return length(max(q, vec3(0.0))) + min(max(q.x, max(q.y, q.z)), 0.0);
  }

  static box2(p: vec2, b: vec2): f32 {
    const q = abs(p) - b;
    return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0);
  }

  static trapezoid(p: vec2, r1: f32, r2: f32, height: f32): f32 {
    const k1 = vec2(r2, height);
    const k2 = vec2(r2 - r1, 2.0 * height);
    const qx = abs(p.x);
    const q = vec2(qx, p.y);

    const ca = vec2(
      qx - min(qx, q.y < 0.0 ? r1 : r2),
      abs(q.y) - height
    );

    const cb =
      q -
      k1 +
      k2 * clamp(dot(k1 - q, k2) / dot(k2, k2), 0.0, 1.0);

    const sign = cb.x < 0.0 && ca.y < 0.0 ? -1.0 : 1.0;

    return sign * sqrt(min(dot(ca, ca), dot(cb, cb)));
  }

  static cylinderY(p: vec3, radius: f32, y0: f32, y1: f32): f32 {
    const d = vec2(
      length(p.xz) - radius,
      abs(p.y - 0.5 * (y0 + y1)) - 0.5 * (y1 - y0)
    );

    return min(max(d.x, d.y), 0.0) + length(max(d, vec2(0.0)));
  }

  static coneY(p: vec3, rb: f32, rt: f32, y0: f32, y1: f32): f32 {
    const halfHeight = 0.5 * (y1 - y0);
    const q = vec2(
      length(p.xz),
      p.y - y0 - halfHeight
    );

    const k1 = vec2(rt, halfHeight);
    const k2 = vec2(rt - rb, 2.0 * halfHeight);

    const ca = vec2(
      q.x - min(q.x, q.y < 0.0 ? rb : rt),
      abs(q.y) - halfHeight
    );

    const cb =
      q -
      k1 +
      k2 * clamp(dot(k1 - q, k2) / dot(k2, k2), 0.0, 1.0);

    const sign = cb.x < 0.0 && ca.y < 0.0 ? -1.0 : 1.0;

    return sign * sqrt(min(dot(ca, ca), dot(cb, cb)));
  }

  static cutSphereBelow(p: vec3, radius: f32, height: f32): f32 {
    const width = sqrt(radius * radius - height * height);
    const q = vec2(length(p.xz), -p.y);
    const h = -height;

    const s = max(
      (h - radius) * q.x * q.x +
        width * width * (h + radius - 2.0 * q.y),
      h * q.x - width * q.y
    );

    return s < 0.0
      ? length(q) - radius
      : q.x < width
        ? h - q.y
        : length(q - vec2(width, h));
  }

  static cylinderSegment(p: vec3, a: vec3, b: vec3, radius: f32): f32 {
    const pa = p - a;
    const ba = b - a;
    const lengthAB = length(ba);
    const h = dot(pa, ba) / lengthAB;

    const d = vec2(
      length(pa - ba * (h / lengthAB)) - radius,
      abs(h - 0.5 * lengthAB) - 0.5 * lengthAB
    );

    return min(max(d.x, d.y), 0.0) + length(max(d, vec2(0.0)));
  }

  static capsule(p: vec3, a: vec3, b: vec3, radius: f32): f32 {
    const pa = p - a;
    const ba = b - a;

    return length(
      pa - ba * clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0)
    ) - radius;
  }

  static rotate(p: vec2, angle: f32): vec2 {
    const c = cos(angle);
    const s = sin(angle);

    return mat2(c, s, -s, c) * p;
  }

  static arch(p: vec2, halfWidth: f32, y0: f32, y1: f32): f32 {
    const d =
      length(
        vec2(
          p.x,
          p.y - clamp(p.y, y0 - halfWidth, y1 - halfWidth)
        )
      ) - halfWidth;

    return max(d, y0 - p.y);
  }

  static polarY(
    angle: f32,
    radius: f32,
    y: f32,
    count: f32,
    offset: f32
  ): PolarResult {
    const sector = TAU / count;
    const shifted = angle - offset;
    const index = floor(shifted / sector + 0.5);
    const localAngle = shifted - index * sector;

    return new PolarResult(
      vec3(
        radius * sin(localAngle),
        y,
        radius * cos(localAngle)
      ),
      index
    );
  }
}

// ---------------------------------------------------------------------
// Ship
// ---------------------------------------------------------------------

class Ship {
  legCount: f32;

  constructor() {
    this.legCount = 4.0;
  }

  flapOutline(s: vec3): f32 {
    const radius = 0.06;
    const angle =
      asin(FLAP_Y) -
      atan(s.y, length(s.xz));

    return SDF.trapezoid(
      vec2(s.x, angle - 0.5 * FLAP_LEN),
      FLAP_HW - radius,
      FLAP_TIP_HW - radius,
      0.5 * FLAP_LEN - radius
    ) - radius;
  }

  flapHome(l: vec3): vec3 {
    const hinge = vec2(
      sqrt(1.0 - FLAP_Y * FLAP_Y),
      FLAP_Y
    );

    const q =
      hinge +
      SDF.rotate(
        l.zy - hinge,
        -FLAP_OPEN
      );

    return vec3(l.x, q.y, q.x);
  }

  flap(l: vec3): f32 {
    const home = this.flapHome(l);

    return max(
      abs(length(home) - 1.0 + 0.5 * FLAP_T) - 0.5 * FLAP_T,
      this.flapOutline(home)
    );
  }

  mapLeg(l: vec3, best: f32): vec2 {
    let y = -0.73;
    const sag = 0.02;

    const hinge = vec2(
      sqrt(1.0 - FLAP_Y * FLAP_Y),
      FLAP_Y
    );

    const tipLat = asin(FLAP_Y) - FLAP_LEN;

    const tip =
      hinge +
      SDF.rotate(
        vec2(cos(tipLat), sin(tipLat)) - hinge,
        FLAP_OPEN
      );

    let bounds = max(
      SDF.segment(l.zy, hinge, tip) - FLAP_T - FLAP_HW,
      abs(l.x) - FLAP_HW
    );

    const lo = vec3(-0.1, GROUND, 0.0);
    const hi = vec3(0.1, y + sag + 0.02, LEG_R + 0.1);

    bounds = min(
      bounds,
      SDF.box(
        l - 0.5 * (lo + hi),
        0.5 * (hi - lo)
      )
    );

    if (bounds > min(best, 0.1)) {
      return vec2(bounds, 5.0);
    }

    const c = l - vec3(0.0, 0.0, LEG_R);

    let leg =
      SDF.cylinderY(
        c,
        0.05,
        SHOCK_Y + 0.01,
        -0.72
      ) - 0.01;

    leg = min(
      leg,
      SDF.cylinderY(
        c,
        0.025,
        GROUND + 0.05,
        SHOCK_Y
      )
    );

    leg = min(
      leg,
      SDF.capsule(
        l,
        vec3(0.0, y + sag, 0.6),
        vec3(0.0, y, LEG_R),
        0.018
      )
    );

    const ySeparation = 0.09;
    const xSeparation = 0.05;
    const joint = LEG_R - 0.2;

    leg = min(
      leg,
      SDF.capsule(
        l,
        vec3(-xSeparation, y, 0.6),
        vec3(-xSeparation, y - ySeparation, joint),
        0.018
      )
    );

    leg = min(
      leg,
      SDF.capsule(
        l,
        vec3(xSeparation, y, 0.6),
        vec3(xSeparation, y - ySeparation, joint),
        0.018
      )
    );

    y -= ySeparation;

    const jointWidth = xSeparation + 0.02;

    leg = min(
      leg,
      SDF.cylinderSegment(
        l,
        vec3(-jointWidth, y + sag, joint),
        vec3(jointWidth, y + sag, joint),
        0.05
      )
    );

    leg = min(
      leg,
      SDF.capsule(
        l,
        vec3(xSeparation, y + sag, joint),
        vec3(xSeparation, y, LEG_R),
        0.012
      )
    );

    leg = min(
      leg,
      SDF.capsule(
        l,
        vec3(-xSeparation, y + sag, joint),
        vec3(-xSeparation, y, LEG_R),
        0.012
      )
    );

    leg = min(
      leg,
      SDF.capsule(
        l,
        vec3(xSeparation, y + sag, joint),
        vec3(xSeparation, y, 0.2),
        0.012
      )
    );

    leg = min(
      leg,
      SDF.capsule(
        l,
        vec3(-xSeparation, y + sag, joint),
        vec3(-xSeparation, y, 0.2),
        0.012
      )
    );

    let result = vec2(leg, 5.0);

    result = SDF.union(
      result,
      vec2(
        SDF.coneY(
          c,
          0.094,
          0.024,
          GROUND + 0.006,
          GROUND + 0.064
        ) - 0.006,
        6.0
      )
    );

    result = SDF.union(
      result,
      vec2(this.flap(l), 10.0)
    );

    return result;
  }

  map(p: vec3): vec2 {
    const bound =
      length(p - vec3(0.0, -0.3, 0.0)) - 1.6;

    if (bound > 0.25) {
      return vec2(bound, 0.0);
    }

    const radius = length(p);
    const longitude = atan(p.x, p.z);
    const horizontalRadius = length(p.xz);

    const legFrame = SDF.polarY(
      longitude,
      horizontalRadius,
      p.y,
      this.legCount,
      LEG_LON
    );

    const l = legFrame.point;

    const bay = max(
      this.flapOutline(l),
      1.0 - FLAP_T - BAY_DEPTH - radius
    );

    let result =
      radius - 1.0 > -bay
        ? vec2(radius - 1.0, 0.0)
        : vec2(-bay, 11.0);

    if (BELLY_Y - p.y > result.x) {
      result = vec2(BELLY_Y - p.y, 12.0);
    }

    result = SDF.union(
      result,
      vec2(
        max(
          radius - 1.022,
          abs(p.y - RING_Y) - RING_H
        ),
        1.0
      )
    );

    const door =
      SDF.arch(
        vec2(longitude * horizontalRadius, p.y),
        DOOR_HW,
        DOOR_Y0,
        DOOR_Y1
      );

    result = SDF.union(
      result,
      vec2(
        max(radius - 1.0 - DOOR_T, door),
        4.0
      )
    );

    const windowFrame = SDF.polarY(
      longitude,
      horizontalRadius,
      p.y,
      WIN_N,
      0.0
    );

    if (abs(windowFrame.index) > 0.5) {
      const center = vec3(
        0.0,
        WIN_Y,
        sqrt(1.0 - WIN_Y * WIN_Y)
      );

      const w = windowFrame.point - center;
      const h = dot(w, center);
      const glassRadius =
        (WIN_A * WIN_A + WIN_BULGE * WIN_BULGE) /
        (2.0 * WIN_BULGE);

      result = SDF.union(
        result,
        vec2(
          length(
            windowFrame.point -
            center * (1.0 + WIN_BULGE - glassRadius)
          ) - glassRadius,
          2.0
        )
      );

      result = SDF.union(
        result,
        vec2(
          length(
            vec2(
              length(w - h * center) - 0.114,
              h
            )
          ) - 0.017,
          3.0
        )
      );
    }

    result = SDF.union(
      result,
      this.mapLeg(l, result.x)
    );

    const bellyPoint =
      p + vec3(0.0, BELLY_EXT, 0.0);

    result = SDF.union(
      result,
      vec2(
        SDF.cutSphereBelow(
          bellyPoint,
          1.0,
          BELLY_Y
        ),
        bellyPoint.y - BELLY_Y > length(bellyPoint) - 1.0
          ? 12.0
          : 0.0
      )
    );

    result = SDF.union(
      result,
      vec2(
        SDF.cylinderY(
          p,
          SHAFT_R,
          BELLY_Y - BELLY_EXT - 0.01,
          BELLY_Y + 0.05
        ),
        7.0
      )
    );

    return result;
  }
}

// ---------------------------------------------------------------------
// Trees
// ---------------------------------------------------------------------

class NamekTree {
  position: vec2;
  height: f32;
  radius: f32;

  constructor(position: vec2, height: f32, radius: f32) {
    this.position = position;
    this.height = height;
    this.radius = radius;
  }

  map(p: vec3): vec2 {
    const q =
      p -
      vec3(
        this.position.x,
        GROUND,
        this.position.y
      );

    const bound =
      length(
        q - vec3(0.0, 0.5 * this.height, 0.0)
      ) -
      0.5 * this.height -
      this.radius -
      0.1;

    if (bound > 0.2) {
      return vec2(bound, 8.0);
    }

    const taper =
      mix(
        0.018,
        0.012,
        clamp(q.y / this.height, 0.0, 1.0)
      );

    const trunk =
      length(
        vec2(
          length(q.xz),
          q.y - clamp(q.y, -0.2, this.height)
        )
      ) -
      taper;

    const canopyCenter =
      q -
      vec3(0.0, this.height, 0.0);

    const normal =
      canopyCenter /
      (length(canopyCenter) + 1e-4);

    const bump =
      sin(9.0 * normal.x + 1.0) *
      sin(9.0 * normal.y + 2.0) *
      sin(9.0 * normal.z + 3.0);

    const leaves =
      (
        length(canopyCenter) -
        this.radius * (1.0 + 0.07 * bump)
      ) *
      0.8;

    return SDF.union(
      vec2(trunk, 8.0),
      vec2(leaves, 9.0)
    );
  }

  shadowRadius(): f32 {
    return 0.5 * this.height + this.radius + 0.1;
  }

  shadowCenter(): vec3 {
    return vec3(
      this.position.x,
      GROUND + 0.5 * this.height,
      this.position.y
    );
  }
}

class NamekTrees {
  first: NamekTree;
  second: NamekTree;

  constructor() {
    this.first = new NamekTree(
      vec2(1.86, 0.83),
      0.65,
      0.15
    );

    this.second = new NamekTree(
      vec2(-5.9, -4.4),
      1.9,
      0.38
    );
  }

  map(p: vec3): vec2 {
    let result = this.first.map(p);
    result = SDF.union(result, this.second.map(p));
    return result;
  }

  nearest(p: vec3): NamekTree {
    const firstDistance =
      length(p.xz - this.first.position);

    const secondDistance =
      length(p.xz - this.second.position);

    return firstDistance < secondDistance
      ? this.first
      : this.second;
  }

  casterExit(ro: vec3, rd: vec3, current: f32): f32 {
    const firstExit = this.sphereExit(
      ro,
      rd,
      this.first.shadowCenter(),
      this.first.shadowRadius()
    );

    const secondExit = this.sphereExit(
      ro,
      rd,
      this.second.shadowCenter(),
      this.second.shadowRadius()
    );

    return max(current, max(firstExit, secondExit));
  }

  private sphereExit(
    ro: vec3,
    rd: vec3,
    center: vec3,
    radius: f32
  ): f32 {
    const oc = ro - center;
    const b = dot(oc, rd);
    const h =
      b * b -
      dot(oc, oc) +
      radius * radius;

    return h > 0.0
      ? -b + sqrt(h)
      : 0.0;
  }
}

// ---------------------------------------------------------------------
// Ship surface details
// ---------------------------------------------------------------------

class ShipDetails {
  private ship: Ship;
  private trees: NamekTrees;

  constructor(ship: Ship, trees: NamekTrees) {
    this.ship = ship;
    this.trees = trees;
  }

  private tickRow(
    q: vec2,
    seed: vec2,
    n: i32,
    gap: f32,
    len: f32,
    bend: f32,
    curl: f32,
    spread: f32,
    lw: f32
  ): f32 {
    const mid = 0.5 * f32(n - 1);
    const radius = 2.0 * mid * gap / PI;
    const bendSign = bend < 0.0 ? -1.0 : 1.0;

    let distance = 1e9;

    for (let k = 0; k < 9; k++) {
      if (k >= n) {
        break;
      }

      const fk = (f32(k) - mid) / mid;

      const h = vec4(
        Noise.hash12(seed + f32(k) * 3.1 + 51.0),
        Noise.hash12(seed + f32(k) * 5.7 + 63.0),
        Noise.hash12(seed + f32(k) * 2.3 + 77.0),
        Noise.hash12(seed + f32(k) * 4.9 + 89.0)
      );

      let base = vec2(
        gap * (f32(k) - mid),
        bend * fk * fk + max(-bend, 0.0)
      );

      base = mix(
        base,
        radius *
          vec2(
            sin(0.5 * PI * fk),
            bendSign * (1.0 - cos(0.5 * PI * fk))
          ),
        curl
      );

      base += vec2(
        0.4 * gap * (h.x - 0.5),
        0.35 * len * h.y
      );
      const bladeLength =
        len *
        (1.0 - spread * Noise.hash12(seed + f32(k) * 6.1 + 97.0));

      const angle = 0.5 * (h.z - 0.5);

      distance = min(
        distance,
        SDF.segment(
          q,
          base,
          base +
            bladeLength *
            vec2(
              sin(angle),
              cos(angle)
            )
        ) -
        (0.5 + h.w) * lw
      );
    }

    return distance;
  }

  scuffInk(
    p: vec3,
    t: f32,
    lineA: f32,
    aa: f32
  ): f32 {
    const cellA =
      max(
        sqrt(0.6 / SCUFFS),
        0.12
      );

    const fill =
      SCUFFS *
      cellA *
      cellA;

    const normal = normalize(p);

    const lat = asin(clamp(normal.y, -1.0, 1.0));
    const lon = atan(normal.x, normal.z);

    const lw = 0.2 * lineA * t;

    let distance = 1e9;

    for (let j = -1; j <= 1; j++) {
      const ring = floor(lat / cellA) + f32(j);
      const ringCells =
        max(
          1.0,
          floor(
            TAU *
            cos((ring + 0.5) * cellA) /
            cellA
          )
        );

      const deltaAngle = TAU / ringCells;

      for (let i = -1; i <= 1; i++) {
        const cell = vec2(
          mod(
            floor(lon / deltaAngle) + f32(i),
            ringCells
          ),
          ring
        );

        if (Noise.hash12(cell * 1.17 + 3.9) > fill) {
          continue;
        }

        const g = vec4(
          Noise.hash12(cell + 7.3),
          Noise.hash12(cell + 19.1),
          Noise.hash12(cell + 23.9),
          Noise.hash12(cell + 37.3)
        );

        const cellLat =
          (ring + 0.25 + 0.5 * g.x) * cellA;

        const cellLon =
          (cell.x + 0.25 + 0.5 * g.y) * deltaAngle;

        const center = vec3(
          cos(cellLat) * sin(cellLon),
          sin(cellLat),
          cos(cellLat) * cos(cellLon)
        );

        const east = vec3(
          cos(cellLon),
          0.0,
          -sin(cellLon)
        );

        const local = SDF.rotate(
          vec2(
            dot(normal - center, east),
            dot(
              normal - center,
              cross(center, east)
            )
          ),
          2.4 * (g.z - 0.5)
        );

        if (dot(local, local) > 0.0225) {
          continue;
        }

        const hatchTest =
          SDF.arch(
            vec2(
              atan(center.x, center.z) * length(center.xz),
              center.y
            ),
            DOOR_HW,
            DOOR_Y0,
            DOOR_Y1
          );

        if (hatchTest < 0.15) {
          continue;
        }

        const h = vec4(
          Noise.hash12(cell + 41.7),
          Noise.hash12(cell + 53.3),
          Noise.hash12(cell + 67.1),
          Noise.hash12(cell + 79.9)
        );

        const count = 2 + i32(7.0 * g.w);
        const gap = 0.016 * (0.75 + 0.5 * h.x);
        const len = 0.001 + 0.044 * h.y * h.y;
        const bend =
          0.6 *
          gap *
          0.5 *
          f32(count - 1) *
          (2.0 * h.z - 1.0);
        const curl = step(0.85, h.w);

        distance = min(
          distance,
          this.tickRow(
            local,
            cell + 101.0,
            count,
            gap,
            len,
            bend,
            curl,
            0.7,
            lw
          )
        );
      }
    }

    return 1.0 - smoothstep(-aa, aa, distance);
  }

  hatchedSheen(
    normal: vec3,
    viewRight: vec3,
    viewUp: vec3,
    aa: f32
  ): f32 {
    const sheenCenter =
      0.6 * viewUp -
      0.8 * viewRight;

    const c0 =
      cos(
        SHEEN_R +
        0.1 *
        (
          Noise.value(
            7.0 *
            vec2(
              dot(normal, viewRight),
              dot(normal, viewUp)
            )
          ) -
          0.5
        )
      );

    const rho = max(length(normal.xz), 1e-4);
    const hH = length(sheenCenter.xz);
    const m = normal.y * sheenCenter.y;
    const w = rho * hH;
    const angle =
      acos(
        clamp(
          dot(normal.xz, sheenCenter.xz) / w,
          -1.0,
          1.0
        )
      );

    const q = (c0 - m) / w;

    let e = 0.0;
    let de = 0.0;

    if (abs(q) < 1.0) {
      const lit = acos(q);

      e = rho * (lit - angle);

      de =
        -normal.y * (lit - angle) -
        (
          c0 * normal.y -
          sheenCenter.y
        ) /
        (
          w *
          max(
            sqrt(1.0 - q * q),
            0.05
          )
        );
    } else {
      const hm =
        m +
        sign(q) * w;

      const gap =
        abs(c0 - hm) /
        sqrt(
          max(
            1.0 - hm * hm,
            1e-4
          )
        );

      const arc =
        q > 0.0
          ? angle
          : PI - angle;

      e =
        -sign(q) *
        (gap + rho * arc);

      de =
        sign(q) *
        normal.y *
        arc;
    }

    const s =
      asin(
        clamp(normal.y, -1.0, 1.0)
      ) /
      HATCH_W;

    const c1 = floor(s + 0.5);
    const u1 = s - c1;

    const c2 = floor(s);
    const u2 = s - c2 - 0.5;

    const fit =
      min(
        1.0,
        1.25 *
        rho /
        max(HATCH_L, HATCH_D)
      );

    const lightLength =
      fit *
      HATCH_L *
      (
        0.15 +
        0.85 *
        Noise.hash12(vec2(c1, 3.1))
      );

    const darkLength =
      fit *
      HATCH_D *
      (
        0.15 +
        0.85 *
        Noise.hash12(vec2(c2, 7.7))
      );

    const z =
      e +
      lightLength * (1.0 - 2.0 * abs(u1)) -
      darkLength * (1.0 - 2.0 * abs(u2));

    const dz =
      de +
      2.0 *
      (
        darkLength * sign(u2) -
        lightLength * sign(u1)
      ) /
      HATCH_W;

    return smoothstep(
      -aa,
      aa,
      z / sqrt(1.0 + dz * dz)
    );
  }
}

// ---------------------------------------------------------------------
// Lettering
// ---------------------------------------------------------------------

class Lettering {
  strokeWidth: f32;

  constructor() {
    this.strokeWidth = SW;
  }

  rect(
    p: vec2,
    x0: f32,
    y0: f32,
    x1: f32,
    y1: f32
  ): f32 {
    return SDF.box2(
      p - 0.5 * vec2(x0 + x1, y0 + y1),
      0.5 * vec2(x1 - x0, y1 - y0)
    );
  }

  stroke(p: vec2, a: vec2, b: vec2): f32 {
    return SDF.segment(p, a, b) - this.strokeWidth;
  }

  arc(
    p: vec2,
    center: vec2,
    radius: f32,
    start: f32,
    end: f32
  ): f32 {
    const q = p - center;

    if (mod(atan(q.y, q.x) - start, TAU) < end - start) {
      return abs(length(q) - radius) - this.strokeWidth;
    }

    return min(
      length(q - radius * vec2(cos(start), sin(start))),
      length(q - radius * vec2(cos(end), sin(end)))
    ) - this.strokeWidth;
  }

  glyph(p: vec2, glyphId: i32): f32 {
    let d = 0.0;

    if (glyphId === 0) {
      d = min(
        this.arc(
          p,
          vec2(0.0, 0.2),
          0.215,
          0.5,
          PI
        ),
        this.arc(
          p,
          vec2(0.0, -0.2),
          0.215,
          PI,
          TAU - 0.5
        )
      );

      d = min(
        d,
        this.rect(
          p,
          -0.3,
          -0.2,
          -0.13,
          0.2
        )
      );
    } else if (glyphId === 1) {
      d = min(
        this.stroke(
          p,
          vec2(-0.245, -0.6),
          vec2(-0.01, 0.6)
        ),
        this.stroke(
          p,
          vec2(0.245, -0.6),
          vec2(0.01, 0.6)
        )
      );

      d = min(
        d,
        this.rect(
          p,
          -0.16,
          -0.22,
          0.16,
          -0.06
        )
      );
    } else if (glyphId === 2 || glyphId === 8) {
      d = min(
        this.rect(
          p,
          -0.28,
          -0.5,
          -0.11,
          0.5
        ),
        this.rect(
          p,
          -0.28,
          0.33,
          0.0,
          0.5
        )
      );

      d = min(
        d,
        this.rect(
          p,
          -0.28,
          -0.08,
          0.0,
          0.09
        )
      );

      d = min(
        d,
        this.arc(
          p,
          vec2(0.0, 0.205),
          0.21,
          -0.5 * PI,
          0.5 * PI
        )
      );

      if (glyphId === 8) {
        d = min(
          d,
          this.stroke(
            p,
            vec2(0.0, 0.0),
            vec2(0.24, -0.6)
          )
        );
      }
    } else if (glyphId === 3) {
      d = min(
        this.arc(
          p,
          vec2(0.0, 0.2075),
          0.2075,
          0.4,
          1.5 * PI
        ),
        this.arc(
          p,
          vec2(0.0, -0.2075),
          0.2075,
          PI + 0.4,
          2.5 * PI
        )
      );
    } else if (glyphId === 4) {
      d = min(
        this.rect(
          p,
          -0.3,
          -0.2,
          -0.13,
          0.5
        ),
        this.rect(
          p,
          0.13,
          -0.2,
          0.3,
          0.5
        )
      );

      d = min(
        d,
        this.arc(
          p,
          vec2(0.0, -0.2),
          0.215,
          PI,
          TAU
        )
      );
    } else if (glyphId === 5) {
      d = min(
        this.rect(
          p,
          -0.27,
          -0.5,
          -0.10,
          0.5
        ),
        this.rect(
          p,
          -0.27,
          -0.5,
          0.26,
          -0.33
        )
      );
    } else if (glyphId === 6) {
      d = min(
        this.rect(
          p,
          -0.27,
          -0.5,
          -0.10,
          0.5
        ),
        this.rect(
          p,
          -0.27,
          0.33,
          0.26,
          0.5
        )
      );

      d = min(
        d,
        min(
          this.rect(
            p,
            -0.27,
            -0.085,
            0.2,
            0.085
          ),
          this.rect(
            p,
            -0.27,
            -0.5,
            0.26,
            -0.33
          )
        )
      );
    } else if (glyphId === 7) {
      d =
        abs(
          length(
            vec2(
              p.x,
              p.y - clamp(p.y, -0.2, 0.2)
            )
          ) -
          0.215
        ) - this.strokeWidth;
    } else {
      d =
        length(
          p - vec2(-0.26, -0.4)
        ) -
        0.1;
    }

    return max(d, abs(p.y) - 0.5);
  }

  render(p: vec3, aa: f32): f32 {
    const longitude = atan(p.x, p.z);
    const latitude =
      asin(
        clamp(
          p.y / length(p),
          -1.0,
          1.0
        )
      );

    let ink = 0.0;

    let caps = vec2(
      (longitude - CAPS_LON) * cos(CAPS_LAT),
      latitude - CAPS_LAT
    ) / CAPS_H;

    let index =
      floor(caps.x / ADV + 3.5);

    if (
      abs(caps.y) < 0.6 &&
      index >= 0.0 &&
      index < 7.0
    ) {
      ink =
        1.0 -
        smoothstep(
          -aa,
          aa,
          CAPS_H *
          this.glyph(
            vec2(
              caps.x - (index - 3.0) * ADV,
              caps.y
            ),
            i32(index)
          )
        );
    }

    let corp = vec2(
      (longitude - CORP_LON) * cos(CORP_LAT),
      latitude - CORP_LAT
    ) / CORP_H;

    index =
      floor(corp.x / ADV + 2.5);

    if (
      abs(corp.y) < 0.6 &&
      index >= 0.0 &&
      index < 5.0
    ) {
      const glyphId: i32 =
        index < 0.5
          ? 0
          : index < 1.5
            ? 7
            : index < 2.5
              ? 8
              : index < 3.5
                ? 2
                : 9;

      ink = max(
        ink,
        1.0 -
        smoothstep(
          -aa,
          aa,
          CORP_H *
          this.glyph(
            vec2(
              corp.x - (index - 2.0) * ADV,
              corp.y
            ),
            glyphId
          )
        )
      );
    }

    return ink;
  }
}

// ---------------------------------------------------------------------
// Cel shading and ship material
// ---------------------------------------------------------------------

class ShipMaterial {
  static celShadow(ro: vec3, rd: vec3, ship: Ship, trees: NamekTrees): f32 {
    const pen = 0.4;

    let tmax =
      ShipMaterial.sphereExit(
        ro,
        rd,
        vec3(0.0, -0.3, 0.0),
        1.6 + pen
      );

    tmax =
      trees.casterExit(
        ro,
        rd,
        tmax
      );

    tmax = min(tmax, 9.0);

    if (tmax < 0.02) {
      return 1.0;
    }

    let result = 1.0;
    let t = 0.02;

    for (let i = 0; i < 64; i++) {
      const h =
        ShipMaterial.mapCel(ro + rd * t, ship, trees).x;

      result =
        min(
          result,
          10.0 * h / t
        );

      t += clamp(h, 0.01, 0.3);

      if (result < 0.0 || t > tmax) {
        break;
      }
    }

    return smoothstep(0.25, 0.4, result);
  }

  static shade(
    p: vec3,
    n: vec3,
    rd: vec3,
    id: f32,
    t: f32,
    pixA: f32,
    lineA: f32,
    ship: Ship,
    trees: NamekTrees,
    details: ShipDetails,
    lettering: Lettering
  ): vec3 {
    const aa =
      0.7 *
      pixA *
      t /
      max(dot(n, -rd), 0.3);

    const lw =
      0.5 *
      lineA *
      t;

    let lightAmount =
      smoothstep(
        -0.02,
        0.02,
        dot(n, LIG)
      );

    if (lightAmount > 0.0) {
      lightAmount *=
        ShipMaterial.celShadow(
          p + n * 0.004,
          LIG,
          ship,
          trees
        );
    }

    const lit =
      mix(
        SHADE,
        vec3(1.0),
        lightAmount
      );

    let col = vec3(0.0);

    if (id < 0.5) {
      const cap =
        smoothstep(
          -aa,
          aa,
          p.y - (RING_Y + RING_H)
        );

      const band =
        smoothstep(
          -aa,
          aa,
          p.y - BAND_BOT
        ) -
        smoothstep(
          -aa,
          aa,
          p.y - BAND_TOP
        );

      const viewDirection =
        normalize(t * rd - p);

      const viewRight =
        normalize(
          cross(
            viewDirection,
            vec3(0.0, 1.0, 0.0)
          )
        );

      const sheen =
        details.hatchedSheen(
          n,
          viewRight,
          cross(viewRight, viewDirection),
          aa
        );

      const blackPaint =
        mix(
          BLACK,
          BLACK_HI,
          sheen
        ) *
        mix(
          0.7,
          1.0,
          lightAmount
        );

      const dark =
        max(cap, band);

      col =
        mix(
          HULL * lit,
          blackPaint,
          dark
        );

      col =
        mix(
          col,
          INK,
          max(
            lettering.render(p, aa),
            (1.0 - dark) *
            details.scuffInk(
              p,
              t,
              lineA,
              aa
            )
          )
        );
    } else if (id < 1.5) {
      const dy = abs(p.y - RING_Y);

      col = HULL * lit;

      let ink =
        1.0 -
        smoothstep(
          0.6 * lw - aa,
          0.6 * lw + aa,
          dy
        );

      ink =
        max(
          ink,
          smoothstep(
            RING_H - 1.3 * lw - aa,
            RING_H - 1.3 * lw + aa,
            dy
          )
        );

      col =
        mix(
          col,
          INK,
          ink
        );
    } else if (id < 2.5) {
      const windowFrame =
        SDF.polarY(
          atan(p.x, p.z),
          length(p.xz),
          p.y,
          WIN_N,
          0.0
        );

      const q = windowFrame.point;

      const center = vec3(
        0.0,
        WIN_Y,
        sqrt(1.0 - WIN_Y * WIN_Y)
      );

      const w = vec2(
        q.x,
        dot(
          q - center,
          normalize(
            vec3(0.0, 1.0, 0.0) -
            center * WIN_Y
          )
        )
      );

      const radius = length(w);

      col =
        mix(
          GLASS_HI,
          GLASS_LO,
          smoothstep(
            0.1 - aa,
            0.1 + aa,
            length(
              w -
              vec2(-0.035, 0.04)
            )
          )
        );

      let streak =
        abs(radius - 0.062) -
        0.011;

      streak =
        max(
          streak,
          abs(
            atan(w.y, w.x) - 2.25
          ) *
          radius -
          0.035
        );

      col =
        mix(
          col,
          vec3(0.93, 0.98, 1.0),
          1.0 -
          smoothstep(
            -aa,
            aa,
            streak
          )
        );

      col *=
        mix(
          0.8,
          1.0,
          lightAmount
        );
    } else if (id < 3.5) {
      col = METAL * lit;
    } else if (id < 4.5) {
      const doorDistance =
        SDF.arch(
          vec2(
            atan(p.x, p.z) * length(p.xz),
            p.y
          ),
          DOOR_HW,
          DOOR_Y0,
          DOOR_Y1
        );

      const top =
        length(p) -
        1.0 -
        DOOR_T;

      col = HULL * lit;

      let ink =
        1.0 -
        smoothstep(
          lw - aa,
          lw + aa,
          length(
            vec2(              doorDistance,
              top
            )
          )
        );

      ink =
        max(
          ink,
          1.0 -
          smoothstep(
            0.7 * lw - aa,
            0.7 * lw + aa,
            length(
              vec2(
                doorDistance,
                top + DOOR_T
              )
            )
          )
        );

      col =
        mix(
          col,
          INK,
          ink
        );
    } else if (id < 7.5) {
      col =
        METAL *
        mix(
          vec3(0.50, 0.60, 0.78),
          vec3(1.0),
          lightAmount
        );
    } else if (id < 8.5) {
      col = TRUNK * lit;
    } else if (id < 9.5) {
      const tree =
        trees.nearest(p);

      const normal =
        normalize(
          p -
          vec3(
            tree.position.x,
            GROUND + tree.height,
            tree.position.y
          )
        );

      const k =
        dot(
          normalize(normal + 0.5 * n),
          LIG
        ) +
        0.15 *
        (
          Noise.value(
            normal.xy * 9.0 +
            normal.z * 5.0
          ) -
          0.5
        );

      col =
        mix(
          LEAF * vec3(0.72, 0.78, 0.9),
          LEAF,
          smoothstep(0.0, 0.04, k) *
          ShipMaterial.celShadow(
            p + n * 0.01,
            LIG,
            ship,
            trees
          )
        );

      col =
        mix(
          col,
          LEAF_HI,
          smoothstep(
            0.55,
            0.59,
            k
          )
        );
    } else if (id < 10.5) {
      const legFrame =
        SDF.polarY(
          atan(p.x, p.z),
          length(p.xz),
          p.y,
          4.0,
          LEG_LON
        );

      const local =
        ship.flapHome(
          legFrame.point
        );

      const rotated =
        SDF.rotate(
          local.xz,
          -LEG_LON -
          0.5 * PI *
          legFrame.index
        );

      col =
        mix(
          HULL * lit,
          INK,
          details.scuffInk(
            vec3(
              rotated.x,
              local.y,
              rotated.y
            ),
            t,
            lineA,
            aa
          )
        );
    } else if (id < 11.5) {
      col =
        mix(
          vec3(0.0),
          HULL * lit,
          smoothstep(
            -aa,
            aa,
            length(p) -
            1.0 +
            FLAP_T
          )
        );
    } else {
      col =
        mix(
          vec3(0.0),
          HULL * lit,
          smoothstep(
            -aa,
            aa,
            length(p.xz) -
            BELLY_R +
            FLAP_T
          )
        );
    }

    return col;
  }

  static sphereExit(
    ro: vec3,
    rd: vec3,
    center: vec3,
    radius: f32
  ): f32 {
    const oc = ro - center;
    const b = dot(oc, rd);
    const h =
      b * b -
      dot(oc, oc) +
      radius * radius;

    return h > 0.0
      ? -b + sqrt(h)
      : 0.0;
  }

  static mapCel(p: vec3, ship: Ship, trees: NamekTrees): vec2 {
    let result = ship.map(p);
    result = SDF.union(result, trees.map(p));
    return result;
  }
}

// ---------------------------------------------------------------------
// Terrain
// ---------------------------------------------------------------------

class Terrain {
  static coast(x: vec2): f32 {
    return (
      length(x) -
      COAST_R -
      7.0 *
      (
        Noise.fbm(
          x * 0.09 + 7.0
        ) -
        0.5
      )
    );
  }

  static mesaField(x: vec2): f32 {
    let field =
      Noise.fbm(
        x * 0.05 +
        vec2(4.3, -2.1)
      ) +
      0.04 *
      Noise.value(
        x * 0.8
      );

    field -=
      0.4 *
      (
        1.0 -
        smoothstep(
          MESA_R,
          30.0,
          length(x)
        )
      );

    field +=
      0.25 *
      (
        1.0 -
        smoothstep(
          3.0,
          9.0,
          length(
            x -
            vec2(-21.0, -22.0)
          )
        )
      );

    return field;
  }

  static heightAt(x: vec2): f32 {
    const field = this.mesaField(x);

    return (
      GROUND +
      1.3 *
      smoothstep(
        0.63,
        0.64,
        field
      ) +
      0.55 *
      smoothstep(
        0.71,
        0.72,
        field
      )
    );
  }

  static normal(x: vec2, t: f32): vec3 {
    const stepSize =
      0.02 +
      0.002 * t;

    let gradient = vec2(0.0);

    for (let i = 0; i < 4; i++) {
      gradient -=
        CROSS[i] *
        this.heightAt(
          x +
          stepSize *
          CROSS[i]
        );
    }

    return normalize(
      vec3(
        gradient.x,
        2.0 * stepSize,
        gradient.y
      )
    );
  }

  static march(
    ro: vec3,
    rd: vec3,
    tmax: f32
  ): f32 {
    const ceil = GROUND + 1.9;

    const planeT =
      rd.y < 0.0
        ? (GROUND - ro.y) / rd.y
        : 1e10;

    const a =
      max(
        dot(rd.xz, rd.xz),
        1e-6
      );

    const b =
      dot(ro.xz, rd.xz);

    const c =
      dot(ro.xz, ro.xz) -
      MESA_R * MESA_R;

    let t =
      (
        -b +
        sqrt(
          max(
            b * b - a * c,
            0.0
          )
        )
      ) / a;

    const end =
      min(
        planeT,
        tmax
      );

    let previousT = t;

    for (let i = 0; i < 200; i++) {
      if (t > end) {
        break;
      }

      const p =
        ro +
        rd * t;

      if (
        p.y > ceil &&
        rd.y > 0.0
      ) {
        break;
      }

      const clearance =
        p.y -
        this.heightAt(p.xz);

      if (clearance < 0.0) {
        for (let j = 0; j < 6; j++) {
          const middle =
            0.5 *
            (previousT + t);

          const q =
            ro +
            rd * middle;

          if (
            q.y <
            this.heightAt(q.xz)
          ) {
            t = middle;
          } else {
            previousT = middle;
          }
        }

        return (
          0.5 *
          (previousT + t)
        );
      }

      previousT = t;

      t +=
        max(
          0.3 * clearance,
          0.02 +
          0.015 * t
        );
    }

    return planeT < tmax
      ? planeT
      : -1.0;
  }

  static groundWarp(x: vec2): vec2 {
    const q = vec2(
      Noise.fbm(x * 0.8 + 2.0),
      Noise.fbm(x * 0.8 + 8.3)
    );

    return (
      x +
      1.2 *
      (
        vec2(
          Noise.fbm(
            x * 1.6 +
            3.0 * q +
            1.7
          ),
          Noise.fbm(
            x * 1.6 +
            3.0 * q +
            9.2
          )
        ) -
        0.5
      )
    );
  }

  static dirtField(xw: vec2): f32 {
    return (
      Noise.fbm(
        xw * 0.7 +
        vec2(-13.7, 31.9)
      ) -
      0.62
    );
  }

  static grassInk(
    x: vec2,
    ro: vec3,
    t: f32,
    pixA: f32,
    lineA: f32
  ): f32 {
    const cellSize = 0.4;
    const tickHeight = 0.015;
    const gap = 0.02;

    if (t > 12.0) {
      return 0.0;
    }

    const eye = ro.y - GROUND;
    const view = x - ro.xz;
    const distanceToPoint = length(view);

    const forward =
      view /
      distanceToPoint;

    const side =
      vec2(
        -forward.y,
        forward.x
      );

    const reach =
      distanceToPoint *
      2.2 *
      tickHeight /
      eye;

    const centerCell =
      floor(
        (x -
          0.5 *
          reach *
          forward) /
        cellSize
      );

    let ink = 0.0;

    for (let j = -1; j <= 1; j++) {
      for (let i = -1; i <= 1; i++) {
        const cell =
          centerCell +
          vec2(
            f32(i),
            f32(j)
          );

        if (
          Noise.hash12(
            cell * 1.31 + 5.7
          ) > 0.55
        ) {
          continue;
        }

        const center =
          (
            cell +
            0.2 +
            0.6 *
            vec2(
              Noise.hash12(cell + 7.3),
              Noise.hash12(cell + 19.1)
            )
          ) *
          cellSize;

        const alongRay =
          dot(
            center - ro.xz,
            forward
          );

        if (
          alongRay > distanceToPoint ||
          alongRay <
            distanceToPoint - reach
        ) {
          continue;
        }

        const local =
          vec2(
            dot(
              ro.xz - center,
              side
            ),
            eye *
            (
              1.0 -
              alongRay /
              distanceToPoint
            )
          );

        if (abs(local.x) > 5.0 * gap) {
          continue;
        }

        if (
          this.dirtField(
            this.groundWarp(center)
          ) > -0.04 ||
          this.coast(center) > -0.8
        ) {
          continue;
        }

        const count =
          3 +
          i32(
            5.0 *
            Noise.hash12(
              cell + 31.7
            )
          );

        const bend =
          0.7 *
          tickHeight *
          (
            2.0 *
            Noise.hash12(
              cell + 43.1
            ) -
            1.0
          );

        const rowT =
          t *
          alongRay /
          distanceToPoint;

        const lw =
          0.14 *
          lineA *
          rowT;

        const aa =
          0.7 *
          pixA *
          rowT;

        const distance =
          this.tickRow(
            local,
            cell,
            count,
            gap,
            tickHeight,
            bend,
            0.0,
            0.0,
            lw
          );

        ink =
          max(
            ink,
            1.0 -
            smoothstep(
              -aa,
              aa,
              distance
            )
          );
      }
    }

    return (
      ink *
      (
        1.0 -
        smoothstep(
          8.0,
          12.0,
          t
        )
      )
    );
  }

  static tickRow(
    q: vec2,
    seed: vec2,
    n: i32,
    gap: f32,
    len: f32,
    bend: f32,
    curl: f32,
    spread: f32,
    lw: f32
  ): f32 {
    const mid =
      0.5 *
      f32(n - 1);

    const radius =
      2.0 *
      mid *
      gap /
      PI;

    const signBend =
      bend < 0.0
        ? -1.0
        : 1.0;

    let distance = 1e9;

    for (let k = 0; k < 9; k++) {
      if (k >= n) {
        break;
      }

      const fk =
        (f32(k) - mid) /
        mid;

      const h = vec4(
        Noise.hash12(
          seed +
          f32(k) * 3.1 +
          51.0
        ),
        Noise.hash12(
          seed +
          f32(k) * 5.7 +
          63.0
        ),
        Noise.hash12(
          seed +
          f32(k) * 2.3 +
          77.0
        ),
        Noise.hash12(
          seed +
          f32(k) * 4.9 +
          89.0
        )
      );

      let base = vec2(
        gap *
        (f32(k) - mid),
        bend *
        fk *
        fk +
        max(-bend, 0.0)
      );

      base =
        mix(
          base,
          radius *
          vec2(
            sin(0.5 * PI * fk),
            signBend *
            (
              1.0 -
              cos(0.5 * PI * fk)
            )
          ),
          curl
        );

      base +=
        vec2(
          0.4 *
          gap *
          (h.x - 0.5),
          0.35 *
          len *
          h.y
        );

      const bladeLength =
        len *
        (
          1.0 -
          spread *
          Noise.hash12(
            seed +
            f32(k) * 6.1 +
            97.0
          )
        );

      const angle =
        0.5 *
        (h.z - 0.5);

      distance =
        min(
          distance,
          SDF.segment(
            q,
            base,
            base +
            bladeLength *
            vec2(
              sin(angle),
              cos(angle)
            )
          ) -
          (0.5 + h.w) *
          lw
        );
    }

    return distance;
  }

  static brush(
    a: vec2,
    m: vec2,
    b: vec2,
    width: f32,
    taper: f32
  ): f32 {
    const ma = m - a;
    const bm = b - m;

    const h1 =
      clamp(
        -dot(a, ma) /
        max(
          dot(ma, ma),
          1e-6
        ),
        0.0,
        1.0
      );

    const h2 =
      clamp(
        -dot(m, bm) /
        max(
          dot(bm, bm),
          1e-6
        ),
        0.0,
        1.0
      );

    const d1 =
      length(
        a +
        ma * h1
      );

    const d2 =
      length(
        m +
        bm * h2
      );

    const along =
      d1 < d2
        ? 0.5 * h1
        : 0.5 + 0.5 * h2;

    return (
      min(d1, d2) -
      width *
      mix(
        1.0,
        0.3 +
        0.7 *
        sqrt(
          sin(PI * along)
        ),
        taper
      )
    );
  }

  static dirtStrokes(
    x: vec2,
    eye: vec2,
    jacobian: mat2,
    lw: f32
  ): f32 {
    const cellSize = 0.5;
    const centerCell = floor(x / cellSize);

    let distance = 1e9;

    for (let j = -1; j <= 1; j++) {
      for (let i = -1; i <= 1; i++) {
        const cell =
          centerCell +
          vec2(
            f32(i),
            f32(j)
          );

        if (
          Noise.hash12(
            cell * 1.71 + 2.3
          ) > 0.35
        ) {
          continue;
        }

        const g = vec4(
          Noise.hash12(cell + 11.3),
          Noise.hash12(cell + 23.7),
          Noise.hash12(cell + 31.1),
          Noise.hash12(cell + 47.9)
        );

        const h = vec4(
          Noise.hash12(cell + 53.1),
          Noise.hash12(cell + 61.9),
          Noise.hash12(cell + 71.3),
          Noise.hash12(cell + 83.7)
        );

        const center =
          (
            cell +
            0.25 +
            0.5 *
            g.xy
          ) *
          cellSize;

        const view =
          normalize(
            center -
            eye
          );

        const direction =
          SDF.rotate(
            vec2(
              -view.y,
              view.x
            ),
            DIRT_TILT +
            0.35 *
            (g.z - 0.5)
          );

        const centerPixels =
          jacobian *
          (center - x);

        const directionPixels =
          jacobian *
          direction;

        const directionLength =
          length(
            directionPixels
          );

        const perpendicular =
          vec2(
            -directionPixels.y,
            directionPixels.x
          ) /
          directionLength;

        const spacing =
          0.12 +
          0.025 * h.z;

        const screenSpacing =
          max(
            spacing *
            abs(
              dot(
                jacobian *
                vec2(
                  -direction.y,
                  direction.x
                ),
                perpendicular
              )
            ),
            3.2 * lw
          );

        if (
          length(centerPixels) >
          0.35 *
          directionLength +
          2.0 *
          screenSpacing +
          2.0 *
          lw
        ) {
          continue;
        }

        if (
          this.dirtField(
            this.groundWarp(center)
          ) < 0.015 ||
          this.coast(center) > -1.5
        ) {
          continue;
        }

        const count =
          2 +
          i32(
            2.2 *
            h.y *
            h.y
          );

        const shift =
          0.06 *
          (h.w - 0.5);

        const bow =
          0.2 *
          (h.x - 0.5);

        for (let k = 0; k < 4; k++) {
          if (k >= count) {
            break;
          }

          const offset =
            f32(k) -
            0.5 *
            f32(count - 1);

          const halfLength =
            0.18 *
            (
              0.3 +
              0.7 *
              Noise.hash12(
                cell +
                f32(k) * 7.3 +
                91.0
              )
            );

          const position =
            centerPixels +
            perpendicular *
            offset *
            screenSpacing +
            directionPixels *
            (              offset *
              shift +
              0.06 *
              (
                Noise.hash12(
                  cell +
                  f32(k) * 5.1 +
                  97.0
                ) -
                0.5
              )
            );

          distance =
            min(
              distance,
              this.brush(
                position -
                  directionPixels *
                  halfLength,
                position +
                  perpendicular *
                  bow *
                  halfLength *
                  directionLength,
                position +
                  directionPixels *
                  halfLength,
                0.95 * lw,
                1.0
              )
            );
        }
      }
    }

    return distance;
  }

  static dirtDots(
    x: vec2,
    eye: vec2,
    jacobian: mat2,
    lw: f32
  ): f32 {
    const cellSize = 0.16;
    const centerCell =
      floor(x / cellSize);

    const crowd =
      min(
        1.0,
        cellSize *
        cellSize *
        abs(determinant(jacobian)) /
        (
          60.0 *
          lw *
          lw
        )
      );

    let distance = 1e9;

    for (let j = -1; j <= 1; j++) {
      for (let i = -1; i <= 1; i++) {
        const cell =
          centerCell +
          vec2(
            f32(i),
            f32(j)
          );

        const g = vec4(
          Noise.hash12(cell + 5.9),
          Noise.hash12(cell + 17.3),
          Noise.hash12(cell + 29.5),
          Noise.hash12(cell + 43.1)
        );

        const center =
          (
            cell +
            0.1 +
            0.8 *
            g.xy
          ) *
          cellSize;

        const probability =
          0.03 +
          0.7 *
          smoothstep(
            0.5,
            0.72,
            Noise.value(
              center * 2.2 +
              71.0
            )
          );

        if (
          Noise.hash12(
            cell * 1.37 + 9.1
          ) >
          probability *
          crowd
        ) {
          continue;
        }

        const centerPixels =
          jacobian *
          (center - x);

        if (
          dot(
            centerPixels,
            centerPixels
          ) >
          16.0 *
          lw *
          lw
        ) {
          continue;
        }

        if (
          this.dirtField(
            this.groundWarp(center)
          ) < 0.03 ||
          this.coast(center) > -1.0
        ) {
          continue;
        }

        const view =
          normalize(
            center -
            eye
          );

        const direction =
          normalize(
            jacobian *
            SDF.rotate(
              vec2(
                -view.y,
                view.x
              ),
              DIRT_TILT +
              0.6 *
              (g.z - 0.5)
            )
          );

        const halfLength =
          (
            0.3 +
            1.5 *
            g.w *
            g.w
          ) *
          lw;

        const edge =
          halfLength *
          direction;

        distance =
          min(
            distance,
            this.brush(
              centerPixels - edge,
              centerPixels,
              centerPixels + edge,
              (
                1.0 +
                0.35 *
                Noise.hash12(
                  cell + 57.7
                )
              ) *
              lw,
              0.0
            )
          );
      }
    }

    return distance;
  }

  static dirtInk(
    x: vec2,
    eye: vec2,
    t: f32,
    jacobian: mat2,
    lw: f32
  ): f32 {
    if (t > 14.0) {
      return 0.0;
    }

    const distance =
      min(
        this.dirtStrokes(
          x,
          eye,
          jacobian,
          lw
        ),
        this.dirtDots(
          x,
          eye,
          jacobian,
          lw
        )
      );

    return (
      (
        1.0 -
        smoothstep(
          -0.5,
          0.5,
          distance
        )
      ) *
      (
        1.0 -
        smoothstep(
          10.0,
          14.0,
          t
        )
      )
    );
  }

  static shade(
    p: vec3,
    ro: vec3,
    t: f32,
    pixA: f32,
    lineA: f32,
    ship: Ship,
    trees: NamekTrees
  ): vec3 {
    const normal =
      length(p.xz) > MESA_R
        ? this.normal(p.xz, t)
        : vec3(0.0, 1.0, 0.0);

    const x = p.xz;
    const radius = length(x);
    const height = p.y - GROUND;

    const warped =
      this.groundWarp(x);

    let col =
      mix(
        GRASS,
        GRASS_DK,
        0.6 *
        smoothstep(
          0.55,
          0.56,
          Noise.fbm(
            warped * 1.1 + 3.0
          )
        )
      );

    let dirt = this.dirtField(warped);

    const dirtGradient =
      vec2(
        dpdx(dirt),
        dpdy(dirt)
      );

    const recede =
      smoothstep(
        0.25,
        0.6,
        abs(dirtGradient.x) /
        max(
          length(dirtGradient),
          1e-6
        )
      );

    dirt +=
      0.03 *
      (
        abs(
          Noise.value(x * 5.0) -
          0.5
        ) +
        0.5 *
        abs(
          Noise.value(x * 11.0 + 7.0) -
          0.5
        ) -
        0.35
      );

    const fieldWidth =
      max(
        length(
          vec2(
            dpdx(dirt),
            dpdy(dirt)
          )
        ),
        1e-5
      );

    const dx = dpdx(x);
    const dy = dpdy(x);
    const jacobian = mat2(dx, dy);
    const inverseJacobian = inverseMat2(
      dx.x,
      dx.y,
      dy.x,
      dy.y
    );

    const lw =
      0.5 *
      lineA /
      pixA;

    let grain =
      vec2(
        dot(x, DIRT_GRAIN),
        dot(
          x,
          vec2(
            -DIRT_GRAIN.y,
            DIRT_GRAIN.x
          )
        )
      );

    grain.y +=
      0.8 *
      Noise.fbm(
        x * 0.25 + 5.0
      ) +
      0.12 *
      Noise.value(
        x * 1.9 + 2.0
      );

    let pattern =
      0.5 +
      (
        Noise.fbm(
          vec2(
            0.45 * grain.x,
            3.0 * grain.y
          ) +
          27.0
        ) -
        0.5
      ) *
      (
        0.5 +
        Noise.value(
          x * 0.6 + 31.0
        )
      ) +
      0.7 *
      (
        Noise.value(
          vec2(
            1.2 * grain.x,
            11.0 * grain.y
          ) +
          41.0
        ) -
        0.5
      ) *
      smoothstep(
        0.35,
        0.75,
        Noise.value(
          vec2(
            0.7 * grain.x,
            1.5 * grain.y
          ) +
          13.0
        )
      );

    const dirtColor =
      mix(
        SAND,
        DIRT_DK,
        mix(
          0.36,
          0.6,
          smoothstep(
            0.25,
            0.75,
            pattern
          )
        )
      );

    col =
      mix(
        col,
        dirtColor,
        smoothstep(
          -fieldWidth,
          fieldWidth,
          dirt
        )
      );

    if (
      dirt >
      3.0 *
      fieldWidth *
      lw &&
      abs(
        determinant(jacobian)
      ) > 1e-12
    ) {
      col =
        mix(
          col,
          INK,
          smoothstep(
            3.0,
            5.0,
            dirt /
            fieldWidth /
            lw
          ) *
          this.dirtInk(
            x,
            ro.xz,
            t,
            inverseJacobian,
            lw
          )
        );
    }

    const outline =
      1.0 -
      smoothstep(
        lw - 0.5,
        lw + 0.5,
        abs(dirt) /
        fieldWidth
      );

    const dash =
      smoothstep(
        0.45,
        0.5,
        Noise.value(
          x * 16.0 + 5.0
        )
      );

    col =
      mix(
        col,
        INK,
        outline *
        mix(
          1.0,
          dash,
          recede
        )
      );

    const shoreDistance =
      this.coast(x);

    col =
      mix(
        col,
        SAND_RIM,
        smoothstep(
          -0.75,
          -0.7,
          shoreDistance
        )
      );

    col =
      mix(
        col,
        SAND,
        smoothstep(
          -0.6,
          -0.55,
          shoreDistance
        )
      );

    const fade =
      1.0 -
      smoothstep(
        20.0,
        45.0,
        t
      );

    const lap =
      0.5 +
      0.5 *
      sin(
        1.3 *
        u.time -
        6.0 *
        Noise.value(
          x * 0.4
        )
      );

    let sea =
      mix(
        SEA_SHOAL,
        SEA,
        smoothstep(
          0.9,
          1.0,
          shoreDistance
        )
      );

    const shore =
      -0.35 *
      lap;

    let foam =
      1.0 -
      smoothstep(
        shore + 0.15,
        shore + 0.2,
        shoreDistance
      );

    const mesa =
      this.mesaField(x);

    foam =
      max(
        foam,
        smoothstep(
          0.61 - 0.02 * lap,
          0.615 - 0.02 * lap,
          mesa
        )
      );

    const wave =
      Noise.value(
        vec2(
          3.0 * radius +
          3.0 * Noise.value(
            x * 0.25
          ) +
          1.2 * u.time,
          0.5
        )
      ) *
      Noise.value(
        x * 0.6 + 2.0
      );

    foam =
      max(
        foam,
        fade *
        smoothstep(
          0.5,
          0.53,
          wave
        )
      );

    sea =
      mix(
        sea,
        SEA_HI,
        foam
      );

    col =
      mix(
        col,
        sea,
        smoothstep(
          shore - 0.05,
          shore,
          shoreDistance
        ) *
        (
          1.0 -
          step(
            0.02,
            height
          )
        )
      );

    col =
      mix(
        col,
        INK,
        this.grassInk(
          x,
          ro,
          t,
          pixA,
          lineA
        )
      );

    col =
      mix(
        col,
        MESA_TOP,
        smoothstep(
          0.5,
          1.2,
          height
        )
      );

    const strataValue =
      fract(
        height * 1.3 +
        0.35 *
        Noise.value(
          x * 0.5
        )
      );

    let strata =
      strataValue < 0.45
        ? CLIFF_A
        : strataValue < 0.75
          ? CLIFF_B
          : CLIFF_C;

    strata *=
      0.8 +
      0.2 *
      Noise.value(
        vec2(
          dot(
            x,
            vec2(2.0, 1.3)
          ) *
          3.0,
          height * 0.5
        )
      );

    col =
      mix(
        col,
        strata,
        smoothstep(
          0.35,
          0.55,
          1.0 - normal.y
        )
      );

    let lightAmount =
      smoothstep(
        -0.05,
        0.2,
        dot(normal, LIG)
      );

    if (radius < 12.0) {
      lightAmount *=
        ShipMaterial.celShadow(
          p + normal * 0.02,
          LIG,
          ship,
          trees
        );
    }

    col *=
      mix(
        SHADE * 0.95,
        vec3(1.0),
        lightAmount
      );

    return mix(
      col,
      HAZE,
      1.0 -
      exp(-0.012 * t)
    );
  }
}

// ---------------------------------------------------------------------
// Sky
// ---------------------------------------------------------------------

class Sky {
  static color(rd: vec3): vec3 {
    const y = max(rd.y, 0.0);

    let col =
      mix(
        SKY_HZ,
        SKY_MID,
        smoothstep(
          0.0,
          0.12,
          y
        )
      );

    col =
      mix(
        col,
        SKY_TOP,
        smoothstep(
          0.08,
          0.35,
          y
        )
      );

    const cellCount = 19.0;

    const uv =
      vec2(
        atan(rd.x, rd.z) *
          (cellCount / TAU) +
          0.004 * u.time,
        y * 26.0
      );

    const clouds =
      Noise.fbmWrapX(
        uv *
        vec2(1.0, 0.8) +
        vec2(0.0, 3.0),
        cellCount
      );

    const cloudMask =
      smoothstep(
        0.56,
        0.60,
        clouds
      ) *
      smoothstep(
        0.015,
        0.05,
        y
      ) *
      (
        1.0 -
        smoothstep(
          0.18,
          0.35,
          y
        )
      );

    col =
      mix(
        col,
        CLOUD,
        0.75 *
        cloudMask
      );

    return col;
  }
}

// ---------------------------------------------------------------------
// Full scene renderer
// ---------------------------------------------------------------------

class NamekScene {
  private ship: Ship;
  private trees: NamekTrees;
  private details: ShipDetails;
  private lettering: Lettering;

  constructor() {
    this.ship = new Ship();
    this.trees = new NamekTrees();
    this.details = new ShipDetails(
      this.ship,
      this.trees
    );
    this.lettering = new Lettering();
  }

  private mapCel(p: vec3): vec2 {
    let result =
      this.ship.map(p);

    result =
      SDF.union(
        result,
        this.trees.map(p)
      );

    return result;
  }

  private calcNormal(p: vec3): vec3 {
    let normal = vec3(0.0);

    for (let i = 0; i < 4; i++) {
      normal +=
        TET[i] *
        this.mapCel(
          p +
          0.0007 *
          TET[i]
        ).x;
    }

    return normalize(normal);
  }

  private marchCel(
    ro: vec3,
    rd: vec3,
    lineA: f32,
    pixA: f32
  ): MarchResult {
    let edge = 0.0;
    let windowEdge = 0.0;
    let edgeT = 1e10;

    let t = 0.5;
    let previous = vec2(1e10, -1.0);

    for (let i = 0; i < 180; i++) {
      const hit =
        this.mapCel(
          ro +
          rd * t
        );

      if (
        hit.x <
        0.0003 * t
      ) {
        return new MarchResult(
          vec2(t, hit.y),
          edge,
          windowEdge,
          edgeT
        );
      }

      const width =
        lineA * t;

      if (
        hit.x > previous.x &&
        previous.x < width
      ) {
        const contour =
          clamp(
            (
              width -
              previous.x
            ) /
            (pixA * t),
            0.0,
            1.0
          );

        if (
          abs(
            previous.y -
            2.5
          ) < 1.0
        ) {
          windowEdge =
            max(
              windowEdge,
              contour
            );
        } else {
          edge =
            max(
              edge,
              contour
            );
        }

        edgeT =
          min(
            edgeT,
            t
          );
      }

      previous = hit;
      t += hit.x;

      if (t > 24.0) {
        break;
      }
    }

    return new MarchResult(
      vec2(t, -1.0),
      edge,
      windowEdge,
      edgeT
    );
  }

  private render(
    ro: vec3,
    rd: vec3,
    pixA: f32,
    lineA: f32
  ): vec3 {
    const march =
      this.marchCel(
        ro,
        rd,
        lineA,
        pixA
      );

    let edge =
      march.edge;

    if (
      abs(
        march.hit.y -
        2.5
      ) > 1.0
    ) {
      edge =
        max(
          edge,
          march.winEdge
        );
    }

    const shipHit =
      march.hit.y >= 0.0;

    const terrainT =
      Terrain.march(
        ro,
        rd,
        shipHit
          ? march.hit.x
          : 400.0
      );

    let col = vec3(0.0);
    let frontT = 1e10;

    if (
      shipHit &&
      terrainT < 0.0
    ) {
      const p =
        ro +
        rd *
        march.hit.x;

      col =        ShipMaterial.shade(
          p,
          this.calcNormal(p),
          rd,
          march.hit.y,
          march.hit.x,
          pixA,
          lineA,
          this.ship,
          this.trees,
          this.details,
          this.lettering
        );

      frontT =
        march.hit.x;
    } else if (terrainT > 0.0) {
      col =
        Terrain.shade(
          ro +
          rd *
          terrainT,
          ro,
          terrainT,
          pixA,
          lineA,
          this.ship,
          this.trees
        );

      frontT =
        terrainT;
    } else {
      col =
        Sky.color(rd);
    }

    if (march.tEdge < frontT) {
      col =
        mix(
          col,
          INK,
          edge
        );
    }

    return col;
  }

  colorAt(uv: vec2): vec4 {
    const resolution = u.resolution;
    const fragCoord =
      uv *
      resolution;

    // Camera: slow turn around the ship, or mouse orbit.
    let azimuth =
      0.42 +
      TAU *
      u.time /
      60.0;

    let elevation =
      0.18 +
      0.03 *
      sin(
        0.21 *
        u.time
      );

    if (
      u.mouse.x > 0.0 ||
      u.mouse.y > 0.0
    ) {
      const mouse = u.mouse;

      azimuth =
        0.42 +
        (
          mouse.x -
          0.5
        ) *
        TAU;

      elevation =
        mix(
          0.03,
          1.1,
          mouse.y
        );
    }

    const focalLength = 3.4;

    const sceneTarget =
      vec3(
        0.0,
        -0.25,
        0.0
      );

    const rayOrigin =
      sceneTarget +
      5.6 *
      vec3(
        sin(azimuth) *
          cos(elevation),
        sin(elevation),
        cos(azimuth) *
          cos(elevation)
      );

    const cameraForward =
      normalize(
        sceneTarget -
        rayOrigin
      );

    const cameraRight =
      normalize(
        cross(
          cameraForward,
          vec3(0.0, 1.0, 0.0)
        )
      );

    const cameraUp =
      cross(
        cameraRight,
        cameraForward
      );

    const pixA =
      2.0 /
      (
        resolution.y *
        focalLength
      );

    const lineA =
      (
        0.0055 +
        1.2 /
        resolution.y
      ) /
      focalLength;

    let col =
      vec3(0.0);

    for (let j = 0; j < AA; j++) {
      for (let i = 0; i < AA; i++) {
        const offset =
          (
            vec2(
              f32(i),
              f32(j)
            ) +
            0.5
          ) /
          f32(AA) -
          0.5;

        const screenUV =
          (
            2.0 *
            (
              fragCoord +
              offset
            ) -
            resolution
          ) /
          resolution.y;

        const rd =
          normalize(
            screenUV.x *
              cameraRight +
            screenUV.y *
              cameraUp +
            focalLength *
              cameraForward
          );

        col +=
          this.render(
            rayOrigin,
            rd,
            pixA,
            lineA
          );
      }
    }

    col /=
      f32(AA * AA);

    const q =
      fragCoord /
      resolution;

    // RETRO = 1
    col =
      col *
      vec3(
        0.94,
        1.0,
        0.96
      ) +
      vec3(
        0.025,
        0.035,
        0.03
      );

    col +=
      0.03 *
      (
        Noise.hash12(
          fragCoord +
          61.7 *
          fract(
            u.time *
            7.13
          )
        ) -
        0.5
      );

    col *=
      0.55 +
      0.45 *
      pow(
        16.0 *
        q.x *
        q.y *
        (1.0 - q.x) *
        (1.0 - q.y),
        0.12
      );

    return vec4(
      clamp(
        col,
        vec3(0.0),
        vec3(1.0)
      ),
      1.0
    );
  }
}


class VsOut {
  @builtin("position") pos: vec4;
  @location(0) uv: vec2;
}

@vertex
export function vs(@builtin("vertex_index") vi: u32): VsOut {
  const x = f32(vi & u32(1)) * 4.0 - 1.0;
  const y = f32(vi >> u32(1)) * 4.0 - 1.0;
  const ndc = vec2(x, y);
  return { pos: vec4(x, y, 0.0, 1.0), uv: ndc * 0.5 + vec2(0.5) };
}

// ---------------------------------------------------------------------
// Fragment entry
// ---------------------------------------------------------------------

@diagnostic("off", "derivative_uniformity")
@fragment
export function main(
  @location(0) uv: vec2
): vec4 {
  const scene =
    new NamekScene();

  return scene.colorAt(uv);
}