Skip to content

Instantly share code, notes, and snippets.

@snowclipsed
Created December 31, 2024 22:20
Show Gist options
  • Save snowclipsed/a50b05b8c40315f6f66bfe6c0cdff6cd to your computer and use it in GitHub Desktop.
Save snowclipsed/a50b05b8c40315f6f66bfe6c0cdff6cd to your computer and use it in GitHub Desktop.

Revisions

  1. snowclipsed created this gist Dec 31, 2024.
    493 changes: 493 additions & 0 deletions CyberpunkPerlin.tsx
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,493 @@

    import React, { useState, useEffect, useCallback, useRef } from 'react';

    type ColoredChar = {
    char: string;
    color: string;
    };

    const defaultConfig = {
    scale: 0.05,
    speed: 0.02,
    octaves: 2,
    persistence: 0.5,
    lacunarity: 2.0,
    zoom: 1.0,
    contrast: 1.5,
    heightScale: 1.0
    };


    /**
    * CyberpunkPerlin component generates a visual representation of Perlin noise,
    * which is a type of gradient noise developed by Ken Perlin in 1983.
    * Perlin noise is used in computer graphics for creating textures, terrains, and other
    * procedural content. It is a type of coherent noise, meaning it has a smooth gradient of values
    * across space.
    *
    * This component allows users to switch between a 2D noise flow mode and a 3D landscape
    * mode. Users can also toggle between white and heatmap color modes, and adjust various
    * parameters such as scale, speed, zoom, octaves, persistence, contrast, and height scale
    * using sliders.
    *
    * The component supports mouse interactions for rotating the 3D landscape view and resetting
    * all settings to their default values.
    *
    */
    const CyberpunkPerlin = () => {
    const timeRef = useRef(0);
    const [frame, setFrame] = useState<ColoredChar[][]>([]);
    const animationRef = useRef<number | null>(null);
    const [isLandscape, setIsLandscape] = useState(false);
    const [colorMode, setColorMode] = useState<'white' | 'heatmap'>('white');
    const [rotation, setRotation] = useState({ x: 30, y: 45, z: 0 });
    const [config, setConfig] = useState(defaultConfig);
    const [isDragging, setIsDragging] = useState(false);
    const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 });


    /**
    * Generates a heatmap color based on a given value.
    *
    * @param value - The value to be converted to a color, expected to be in the range [0, 1].
    * @param is3D - Optional boolean indicating if the value is for a 3D point. Defaults to false.
    * @returns A string representing the color in hexadecimal format.
    *
    * This function maps the input value to a color gradient ranging from blue to red.
    * If `is3D` is true, the value is normalized to the range [0, 1] before mapping.
    * The color gradient is defined by a series of color stops, and the function interpolates
    * between these stops to determine the final color.
    */

    const getHeatmapColor = useCallback((value: number, is3D: boolean = false): string => {
    let v;
    if (is3D) {
    // instead of scaling relative to heightScale, i use a fixed range
    v = (value + 1) / 2; // since height values are normalized to [-1, 1]. we're doing this so that when we increase heightScale, the colors don't get into only one range
    v = Math.max(0, Math.min(1, v));
    } else {
    v = Math.max(0, Math.min(1, value));
    }

    const colors = [
    { pos: 0, color: '#000066' },
    { pos: 0.3, color: '#0000FF' },
    { pos: 0.5, color: '#00FFFF' },
    { pos: 0.7, color: '#00FF00' },
    { pos: 0.85, color: '#FFFF00' },
    { pos: 1, color: '#FF0000' }
    ];

    for (let i = 0; i < colors.length - 1; i++) {
    if (v >= colors[i].pos && v <= colors[i + 1].pos) {
    const startColor = colors[i];
    const endColor = colors[i + 1];

    const t = (v - startColor.pos) / (endColor.pos - startColor.pos);

    const start = {
    r: parseInt(startColor.color.slice(1,3), 16),
    g: parseInt(startColor.color.slice(3,5), 16),
    b: parseInt(startColor.color.slice(5,7), 16)
    };

    const end = {
    r: parseInt(endColor.color.slice(1,3), 16),
    g: parseInt(endColor.color.slice(3,5), 16),
    b: parseInt(endColor.color.slice(5,7), 16)
    };

    const r = Math.round(start.r + (end.r - start.r) * t);
    const g = Math.round(start.g + (end.g - start.g) * t);
    const b = Math.round(start.b + (end.b - start.b) * t);

    return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
    }
    }

    return v <= colors[0].pos ? colors[0].color : colors[colors.length - 1].color;
    }, []);


    /**
    * Rotates a 3D point around the x, y, and z axes based on the current rotation state.
    *
    * @param point - A tuple representing the 3D point to be rotated, in the format [x, y, z].
    * @returns A tuple representing the rotated 3D point, in the format [x, y, z].
    *
    * This function applies a series of rotations to the input point:
    * - First, it rotates the point around the x-axis by the angle specified in `rotation.x`.
    * - Then, it rotates the resulting point around the y-axis by the angle specified in `rotation.y`.
    * - Finally, it returns the rotated point.
    *
    * The rotation angles are converted from degrees to radians before applying the rotation.
    */
    const rotatePoint = useCallback((point: [number, number, number]): [number, number, number] => {
    const [x, y, z] = point;
    const toRad = (deg: number) => deg * Math.PI / 180;

    const y1 = y * Math.cos(toRad(rotation.x)) - z * Math.sin(toRad(rotation.x));
    const z1 = y * Math.sin(toRad(rotation.x)) + z * Math.cos(toRad(rotation.x));

    const x2 = x * Math.cos(toRad(rotation.y)) + z1 * Math.sin(toRad(rotation.y));
    const z2 = -x * Math.sin(toRad(rotation.y)) + z1 * Math.cos(toRad(rotation.y));

    return [x2, y1, z2];
    }, [rotation]);


    /**
    * Projects a 3D point onto a 2D plane based on the current configuration.
    *
    * @param point - A tuple representing the 3D point to be projected, in the format [x, y, z].
    * @returns A tuple representing the projected 2D point, in the format [x, y].
    *
    * This function applies a perspective projection to the input point:
    * - The point is scaled based on its distance from the view plane.
    * - A vertical offset is added to keep the terrain centered as the height scale increases.
    * - The resulting 2D coordinates are returned.
    */
    const projectPoint = useCallback((point: [number, number, number]): [number, number] => {
    const viewDistance = 100;
    const [x, y, z] = point;
    const scale = viewDistance / (z + viewDistance);
    // Add vertical offset to keep terrain centered as height scale increases
    const verticalOffset = -config.heightScale * 0.8;
    return [x * scale, (y + verticalOffset) * scale];
    }, [config.heightScale]);


    // Perlin Noise Functions

    /**
    * Generates 2D Perlin noise value for given coordinates.
    *
    * @param x - The x-coordinate.
    * @param y - The y-coordinate.
    * @returns A noise value in the range [0, 1].
    *
    * This function uses a pseudo-random number generator based on sine and cosine functions
    * to produce a smooth noise value. The noise value is interpolated between four surrounding
    * grid points using a fade function.
    */
    const noise2D = useCallback((x: number, y: number): number => {
    const xi = Math.floor(x);
    const yi = Math.floor(y);

    const getRandom = (x: number, y: number) => {
    const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453123;
    return n - Math.floor(n);
    };

    const v00 = getRandom(xi, yi);
    const v10 = getRandom(xi + 1, yi);
    const v01 = getRandom(xi, yi + 1);
    const v11 = getRandom(xi + 1, yi + 1);

    const sx = x - xi;
    const sy = y - yi;
    const nx = (3 - 2 * sx) * sx * sx;
    const ny = (3 - 2 * sy) * sy * sy;

    return v00 * (1 - nx) * (1 - ny) +
    v10 * nx * (1 - ny) +
    v01 * (1 - nx) * ny +
    v11 * nx * ny;
    }, []);


    /**
    * Generates a multi-octave Perlin noise value for given coordinates.
    *
    * @param x - The x-coordinate.
    * @param y - The y-coordinate.
    * @returns A noise value in the range [0, 1].
    *
    * This function combines multiple layers (octaves) of Perlin noise to create a more complex and detailed noise pattern.
    * Each octave has its own frequency and amplitude, which are controlled by the `config` state.
    * The resulting noise value is normalized to the range [0, 1].
    */
    const octaveNoise = useCallback((x: number, y: number): number => {
    let result = 0;
    let amp = 1;
    let freq = 1;
    let maxVal = 0;

    for (let i = 0; i < config.octaves; i++) {
    result += noise2D(x * freq, y * freq) * amp;
    maxVal += amp;
    amp *= config.persistence;
    freq *= config.lacunarity;
    }

    return result / maxVal;
    }, [config.octaves, config.persistence, config.lacunarity, noise2D]);



    /**
    * Creates a 3D landscape frame for the current time.
    *
    * @param currentTime - The current time used to generate the frame.
    * @returns A 2D array of `ColoredChar` representing the 3D landscape frame.
    *
    * This function generates a 3D landscape frame by:
    * - Initializing a buffer and z-buffer for rendering.
    * - Generating terrain points using Perlin noise and rotating/projecting them.
    * - Sorting points by depth and rendering them to the buffer.
    * - Returning the final buffer representing the 3D landscape.
    */
    const create3DLandscapeFrame = useCallback((currentTime: number): ColoredChar[][] => {
    const width = 80;
    const height = 48;
    const terrainSize = 40;
    const chars = ' .,:;~!?▒█';
    const buffer: ColoredChar[][] = Array(height).fill(null).map(() =>
    Array(width).fill({ char: ' ', color: colorMode === 'white' ? '#ffffff' : '#00008B' })
    );
    const zBuffer: number[][] = Array(height).fill(null).map(() => Array(width).fill(-Infinity));

    const points: Array<{
    pos: [number, number, number];
    projected: [number, number];
    brightness: number;
    heightForColor: number;
    }> = [];

    for (let z = -terrainSize/2; z < terrainSize/2; z++) {
    for (let x = -terrainSize/2; x < terrainSize/2; x++) {
    const nx = x * config.scale * config.zoom;
    const nz = z * config.scale * config.zoom;

    let height = octaveNoise(nx + currentTime * 0.1, nz);
    height = Math.pow(height * 0.5 + 0.5, config.contrast) * 2 - 1;
    const heightForColor = height;

    // scale height and shift it to keep terrain centered
    height = (height + 0.5) * config.heightScale;

    const pos: [number, number, number] = [x * 2, height, z * 2];
    const rotated = rotatePoint(pos);
    const projected = projectPoint(rotated);

    points.push({
    pos: rotated,
    projected,
    brightness: height,
    heightForColor: heightForColor
    });
    }
    }

    points.sort((a, b) => b.pos[2] - a.pos[2]);

    points.forEach(point => {
    const [px, py] = point.projected;
    const screenX = Math.floor(px + width/2);
    const screenY = Math.floor(py + height/2);

    if (screenX >= 0 && screenX < width && screenY >= 0 && screenY < height) {
    if (point.pos[2] > zBuffer[screenY][screenX]) {
    zBuffer[screenY][screenX] = point.pos[2];
    const charIndex = Math.floor((point.brightness + 1) * 0.5 * (chars.length - 1));
    const char = chars[Math.max(0, Math.min(chars.length - 1, charIndex))];
    const color = colorMode === 'white' ? '#ffffff' : getHeatmapColor(point.heightForColor, true);
    buffer[screenY][screenX] = { char, color };
    }
    }
    });

    return buffer;
    }, [config, colorMode, getHeatmapColor, octaveNoise, projectPoint, rotatePoint]);


    /**
    * Creates a 2D noise frame for the current time.
    *
    * @param currentTime - The current time used to generate the frame.
    * @returns A 2D array of `ColoredChar` representing the noise frame.
    *
    * This function generates a 2D noise frame by:
    * - Initializing a buffer for rendering.
    * - Generating noise values using Perlin noise and mapping them to characters and colors.
    * - Returning the final buffer representing the 2D noise.
    */
    const createNoiseFrame = useCallback((currentTime: number): ColoredChar[][] => {
    const width = 80;
    const height = 48;
    const chars = ' .:-=+*#%@';
    const buffer: ColoredChar[][] = [];

    for (let y = 0; y < height; y++) {
    const row: ColoredChar[] = [];
    for (let x = 0; x < width; x++) {
    const nx = x * config.scale * config.zoom;
    const ny = y * config.scale * config.zoom;

    let value = octaveNoise(nx + currentTime, ny + currentTime);
    value = Math.pow(value * 0.5 + 0.5, config.contrast);

    const charIndex = Math.floor(value * (chars.length - 0.01));
    const char = chars[Math.max(0, Math.min(chars.length - 1, charIndex))];
    const color = colorMode === 'white' ? '#ffffff' : getHeatmapColor(value);

    row.push({ char, color });
    }
    buffer.push(row);
    }

    return buffer;
    }, [config, colorMode, getHeatmapColor, octaveNoise]);

    const handleMouseDown = useCallback((e: React.MouseEvent) => {
    setIsDragging(true);
    setLastMousePos({ x: e.clientX, y: e.clientY });
    }, []);

    const handleMouseMove = useCallback((e: React.MouseEvent) => {
    if (!isDragging || !isLandscape) return;

    e.preventDefault();
    const currentX = e.clientX;
    const currentY = e.clientY;

    requestAnimationFrame(() => {
    const deltaX = currentX - lastMousePos.x;
    const deltaY = currentY - lastMousePos.y;

    setRotation(prev => ({
    x: prev.x + deltaY * 0.5,
    y: prev.y + deltaX * 0.5,
    z: prev.z
    }));

    setLastMousePos({ x: currentX, y: currentY });
    });
    }, [isDragging, isLandscape, lastMousePos]);

    const handleMouseUp = useCallback(() => {
    setIsDragging(false);
    }, []);

    const handleReset = useCallback(() => {
    setConfig(defaultConfig);
    setRotation({ x: 30, y: 45, z: 0 });
    }, []);

    useEffect(() => {
    const animate = () => {
    timeRef.current += config.speed;
    animationRef.current = requestAnimationFrame(animate);
    };

    animationRef.current = requestAnimationFrame(animate);

    return () => {
    if (animationRef.current !== null) {
    cancelAnimationFrame(animationRef.current);
    }
    };
    }, [config.speed]);

    useEffect(() => {
    const frameId = requestAnimationFrame(() => {
    const newFrame = isLandscape ?
    create3DLandscapeFrame(timeRef.current) :
    createNoiseFrame(timeRef.current);
    setFrame(newFrame);
    });

    return () => cancelAnimationFrame(frameId);
    }, [timeRef.current, isLandscape, create3DLandscapeFrame, createNoiseFrame, rotation, colorMode]);

    const sliders = [
    { key: 'scale', label: 'Pattern Scale', min: 0.01, max: 0.2, step: 0.01 },
    { key: 'speed', label: 'Speed', min: 0, max: 0.05, step: 0.001 },
    { key: 'zoom', label: 'Zoom', min: 0.5, max: 2, step: 0.1 },
    { key: 'octaves', label: 'Detail Layers', min: 1, max: 4, step: 1 },
    { key: 'persistence', label: 'Detail Strength', min: 0.1, max: 0.9, step: 0.1 },
    { key: 'contrast', label: 'Contrast', min: 0.5, max: 2.5, step: 0.1 },
    { key: 'heightScale', label: 'Height Scale', min: 0.5, max: 25.0, step: 0.5 }
    ];

    return (
    <div className="flex border border-white bg-black">
    <div
    className="flex-1 border-r border-white"
    onMouseDown={handleMouseDown}
    onMouseMove={handleMouseMove}
    onMouseUp={handleMouseUp}
    onMouseLeave={handleMouseUp}
    >
    <pre className="font-mono text-xs leading-none whitespace-pre p-1 select-none">
    {frame.map((row, i) => (
    <div key={i}>
    {row.map((cell, j) => (
    <span key={`${i}-${j}`} style={{ color: cell.color }}>
    {cell.char}
    </span>
    ))}
    </div>
    ))}
    </pre>
    </div>

    <div className="w-44 flex flex-col p-1 space-y-1 bg-black text-white">
    <div className="border border-white p-1 text-center text-xs">
    <h3 className="font-bold">NOISE CONTROL MATRIX</h3>
    {isLandscape && <p className="opacity-75 text-xs">Drag to rotate view</p>}
    </div>

    <div className="flex flex-col gap-1">
    <button
    onClick={() => setIsLandscape(prev => !prev)}
    className="border border-white p-1 text-xs font-bold hover:bg-white hover:text-black transition-colors"
    >
    {isLandscape ? 'SWITCH TO FLOW MODE' : 'SWITCH TO 3D MODE'}
    </button>

    <button
    onClick={() => setColorMode(prev => prev === 'white' ? 'heatmap' : 'white')}
    className="border border-white p-1 text-xs font-bold hover:bg-white hover:text-black transition-colors"
    >
    {colorMode === 'white' ? 'SWITCH TO HEATMAP' : 'SWITCH TO WHITE'}
    </button>

    <button
    onClick={handleReset}
    className="border border-white p-1 text-xs font-bold hover:bg-white hover:text-black transition-colors"
    >
    RESET ALL SETTINGS
    </button>
    </div>

    {sliders.map(({ key, label, min, max, step }) => (
    <div key={key} className={`border border-white p-1 ${
    key === 'heightScale' && !isLandscape ? 'opacity-50 pointer-events-none' : ''
    }`}>
    <div className="flex justify-between mb-0.5 text-xs">
    <span className="font-bold">{label}</span>
    <span className="opacity-75">
    {config[key as keyof typeof config].toFixed(3)}
    </span>
    </div>
    <input
    type="range"
    min={min}
    max={max}
    step={step}
    value={config[key as keyof typeof config]}
    onChange={(e) => setConfig(prev => ({
    ...prev,
    [key]: parseFloat(e.target.value)
    }))}
    className="w-full accent-white"
    />
    </div>
    ))}
    </div>
    </div>
    );
    };

    export default CyberpunkPerlin;