├── LICENSE ├── README.md ├── bulb.mjs ├── geo.mjs ├── marchLib.mjs ├── render.mjs ├── run ├── shaderLib.mjs └── spiral.mjs /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Daniel Angell 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | JS Shaders 2 | === 3 | 4 | CPU rendered ASCII art shader writing tool with hot reloading. 5 | 6 | Recording: https://youtu.be/4bbClPPAkQI 7 | 8 | ## Usage 9 | 10 | First create a .mjs file that exports a function named `fragment`. The module should have the following TypeScript signature: 11 | 12 | ```typescript 13 | interface FragmentModule { 14 | /** 15 | * Return a mono-chromatic value for a pixel and time index: 16 | * @param x The x-coordinate of the pixel (In the range 0..1) 17 | * @param y The y-coordinate of the pixel (In the range 0..1) 18 | * @param time The number of seconds since the start of program execution. 19 | * @returns The lightness/darkness of the pixel. 20 | */ 21 | fragment: (x: number, y: number, time: number) => number; 22 | } 23 | ``` 24 | 25 | Then run the module like so: 26 | 27 | `node render.mjs ` 28 | 29 | ## Console Shots 30 | 31 | `node render.mjs ./geo.mjs`: 32 | 33 | ``` 34 | Loaded successfully! 35 | ··@@ 36 | ····@@@@@ 37 | ······@@@@@@@ 38 | ········@@@@@@@@@ **&# 39 | ···@@ ···@@@ ++@@@ ··+**&#@ 40 | ···@@ ···@@@ +·@ ··+**&#@@ 41 | ···@@@@& ···@@@ ··· ··+**&#@ 42 | ··@@@@@@# ···@@@@ ++·····++*&&#@ 43 | ··@@@@@@@#····@@@@++++···· *& 44 | ··@@@@@@@@·····@@@@@+++···· @@ 45 | ··@# ······@@@@@@ @@ 46 | ··@ ·······@@@@@@@@ @@@ 47 | ··· ······&&&&&&&@@@@@@ @@@@ 48 | ··········&&&&&&&&&&&&&@@@@@@@@@@ 49 | ······+&&&&&&&&&&&&&&&&&&&#@@@@@@ 50 | ····&&&&&& &&** &&&&&&@@@@ 51 | ·&&&&&&&& &&&*** &&&&&&&&@ 52 | &&&&&&&@####&&&****+++&&&&&& 53 | &&&&&&&&&***&&&&& 54 | &&&&&&& 55 | ``` 56 | 57 | `node render.mjs ./spiral.mjs`: 58 | 59 | ``` 60 | Reloaded successfully! 61 | @@@@@#################@@@@ ··++**&##@@ 62 | @####&&&&&&&&&&&&&&&&&&&###@@@ ··++*&&##@ 63 | #&&&&*******++++++******&&&&##@@@ ··++*&&#@@ 64 | &****+++++··········+++++***&&###@@ ··+**&##@@ 65 | *+++····· ·····++***&&#@@ ·++*&&#@@ 66 | +··· ···++*&&##@ ··+**&##@ 67 | · ··+**&##@ ··+**&##@ 68 | ·++*&##@ ·++*&##@ 69 | ··+*&#@@ ·++*&##@ 70 | ·++*&#@ ·+**&##@ 71 | @@@@@@@@@@ ·+*&#@ ··+**&#@@ 72 | @@@#####&&&&####@@ +*&#@ ·++*&&#@@ 73 | @@@###&&&*********&&##@ +*&#@ ··+**&##@ 74 | @@###&&***+++········++*&@ +*#@ ·+**&##@ 75 | @@##&&***++··· ·+&@ ·*#@ ·+**&##@@ 76 | @@##&&**++·· * *# ··+**&##@ 77 | @##&&**++·· @+# ··+**&&#@@ 78 | &&**++· @##&*+· *· ··++**&&##@ 79 | &**++· @#&*++ & #&******&&&##@@ 80 | **++· @##&*+· #* @@@###@@@@ 81 | *++· @##&*+· #*· 82 | *+·· @##&*+· #&+· ·· 83 | ++· @#&*++· @#*+· ··++ 84 | +·· @@#&*+· @#&&*+·· ···+++* 85 | +·· @##&*+· @##&**++··· ·····+++***& 86 | +· @##&*+·· @@#&&***++++·····++++++****&&&# 87 | +· @##&*++· @@##&&&*************&&&&####@ 88 | +· @@#&&*+·· @@@###&&&&&&&&&&&#####@@@@ 89 | +·· @##&**+· @@@@########@@@@@@ 90 | +·· @@#&&*++· 91 | ++· @##&&*++·· 92 | ++·· @##&&**+·· 93 | *+·· @##&&**++·· 94 | *++·· @@##&&**++·· 95 | ``` 96 | -------------------------------------------------------------------------------- /bulb.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | distance, 3 | dot, 4 | magnitude, 5 | normalize, 6 | rotate3DX, 7 | rotate3DY, 8 | add, 9 | sub, 10 | abs, 11 | mul, 12 | } from "./shaderLib.mjs"; 13 | import { unionSDF, subtractSDF, rayMarch } from "./marchLib.mjs"; 14 | 15 | const cos = Math.cos, 16 | sin = Math.sin, 17 | acos = Math.acos, 18 | atan = Math.atan, 19 | pow = Math.pow, 20 | log = Math.log, 21 | sqrt = Math.sqrt; 22 | 23 | let globalTime = 0; 24 | 25 | /** 26 | * Signed-distance-function for a Mandelbulb. 27 | * @param point {[number, number, number]} 28 | * @returns {number} 29 | */ 30 | const bulbSDF = (point) => { 31 | let pointIter = [...point]; 32 | 33 | let m = dot(pointIter, pointIter); 34 | let dz = 1.0; 35 | 36 | for (let i = 0; i < 4; i++) { 37 | dz = 8.0 * pow(m, 3.5) * dz + 1.0; 38 | 39 | const [pointIter_x, pointIter_y, pointIter_z] = pointIter; 40 | const radius = magnitude(pointIter); 41 | const b = 8.0 * acos(pointIter_y / radius); 42 | const angle = 8.0 * atan(pointIter_x, pointIter_z); 43 | 44 | pointIter = add( 45 | point, 46 | mul([sin(b) * sin(angle), cos(b), sin(b) * cos(angle)], pow(radius, 8.0)) 47 | ); 48 | m = dot(pointIter, pointIter); 49 | 50 | if (m > 256) break; 51 | } 52 | 53 | return (0.25 * log(m) * sqrt(m)) / dz; 54 | }; 55 | 56 | const sceneSDF = (point) => { 57 | const rotatedPoint = rotate3DY( 58 | rotate3DX(point, Math.sin(globalTime)), 59 | Math.cos(globalTime) 60 | ); 61 | 62 | return bulbSDF(rotatedPoint); 63 | }; 64 | 65 | export const fragment = (x, y, time) => { 66 | globalTime = time / 4; 67 | x -= 0.5; 68 | y -= 0.5; 69 | const source = [0, 0, -3 + 2 * Math.sin(globalTime)]; 70 | const direction = normalize([x, y, 1]); 71 | 72 | return rayMarch(source, direction, sceneSDF); 73 | }; 74 | -------------------------------------------------------------------------------- /geo.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | distance, 3 | normalize, 4 | rotate3DX, 5 | rotate3DY, 6 | sub, 7 | abs, 8 | max, 9 | } from "./shaderLib.mjs"; 10 | import { unionSDF, subtractSDF, rayMarch } from "./marchLib.mjs"; 11 | 12 | let globalTime = 0; 13 | 14 | /** 15 | * Signed-distance-function for a sphere. 16 | * @param point {[number, number, number]} 17 | * @param center {[number, number, number]} 18 | * @param radius {number} 19 | * @returns {number} 20 | */ 21 | const sphereSDF = (point, center, radius) => { 22 | return distance(point, center) - radius; 23 | }; 24 | 25 | /** 26 | * Signed-distance-function for a box. 27 | * @param point {[number, number, number]} 28 | * @param size {[number, number, number]} 29 | * @returns {number} 30 | */ 31 | const boxSDF = (point, size) => { 32 | const center = [0.0, 0.0, 0.0]; 33 | const q = sub(abs(point), size); 34 | 35 | return distance(max(q, 0.0), center) + Math.min(Math.max(...q), 0.0); 36 | }; 37 | 38 | const sceneSDF = (point) => { 39 | const rotatedPoint = rotate3DY( 40 | rotate3DX(point, Math.sin(globalTime)), 41 | Math.cos(globalTime) 42 | ); 43 | 44 | return unionSDF( 45 | subtractSDF( 46 | boxSDF(rotatedPoint, [0.9, 0.9, 0.9]), 47 | sphereSDF(point, [0, 0, 0], 1.1) 48 | ), 49 | sphereSDF(rotatedPoint, [Math.sin(globalTime) * 2.5, 0, 0], 0.3) 50 | ); 51 | }; 52 | 53 | export const fragment = (x, y, time) => { 54 | globalTime = time; 55 | x -= 0.5; 56 | y -= 0.5; 57 | const source = [0, 0, -5]; 58 | const direction = normalize([x, y, 1]); 59 | 60 | return rayMarch(source, direction, sceneSDF); 61 | }; 62 | -------------------------------------------------------------------------------- /marchLib.mjs: -------------------------------------------------------------------------------- 1 | import { add, mul, normalize } from "./shaderLib.mjs"; 2 | 3 | export const intersectSDF = (a, b) => Math.max(a, b); 4 | export const subtractSDF = (a, b) => Math.max(a, -b); 5 | export const unionSDF = (a, b) => Math.min(a, b); 6 | 7 | const STEP = 0.001; 8 | 9 | /** 10 | * Get the surface normal at a point. 11 | * @param sceneSDF {(point: [number, number, number]) => number} 12 | * @param point {[number, number, number]} 13 | * @returns {[number, number, number]} 14 | */ 15 | const normal = (sceneSDF, point) => { 16 | const gradient0 = sceneSDF(point); 17 | const gradientX = gradient0 - sceneSDF(add(point, [STEP, 0, 0])); 18 | const gradientY = gradient0 - sceneSDF(add(point, [0, STEP, 0])); 19 | const gradientZ = gradient0 - sceneSDF(add(point, [0, 0, STEP])); 20 | 21 | return normalize([gradientX, gradientY, gradientZ]); 22 | }; 23 | 24 | const MAX_STEPS = 32; 25 | const MAX_DISTANCE = 10; 26 | const HIT_DISTANCE = 0.002; 27 | 28 | /** 29 | * Perform ray marching. 30 | * @param source {[number, number, number]} 31 | * @param direction {[number, number, number]} 32 | * @param sceneSDF {(point: [number, number, number]) => number} 33 | * @returns {number} 34 | */ 35 | export const rayMarch = (source, direction, sceneSDF) => { 36 | let distanceTraveled = 0; 37 | 38 | for (let i = 0; i < MAX_STEPS; ++i) { 39 | const currentPos = add(source, mul(direction, distanceTraveled)); 40 | 41 | const distanceToScene = sceneSDF(currentPos); 42 | if (distanceToScene < HIT_DISTANCE) 43 | return normal(sceneSDF, currentPos)[0] * 0.5 + 0.6; 44 | 45 | distanceTraveled += distanceToScene; 46 | if (distanceTraveled > MAX_DISTANCE) break; 47 | } 48 | 49 | return 0; 50 | }; 51 | -------------------------------------------------------------------------------- /render.mjs: -------------------------------------------------------------------------------- 1 | import { cursorTo, clearScreenDown } from "readline"; 2 | import fs from "fs"; 3 | 4 | /** 5 | * Promisify a function from the `readline` package. 6 | * @param func {(stream: NodeJS.WritableStream, ...args: any[]) => void} 7 | * @param args {any[]} 8 | * @returns 9 | */ 10 | const screen = async (func, ...args) => { 11 | return new Promise((resolve) => func(process.stdout, ...args, resolve)); 12 | }; 13 | 14 | const reset = async () => { 15 | await screen(cursorTo, 0, 0); 16 | await screen(clearScreenDown); 17 | }; 18 | 19 | /** 20 | * Write data to the terminal at the current cursor position. 21 | * @param data {string|Buffer} 22 | * @returns {Promise} 23 | */ 24 | const write = async (data) => { 25 | return new Promise((resolve, reject) => { 26 | process.stdout.write(data, (error) => { 27 | error ? reject(error) : resolve(); 28 | }); 29 | }); 30 | }; 31 | 32 | /** 33 | * Delay for at least `timeout` milliseconds. 34 | * @param timeout {number} 35 | * @returns {Promise} 36 | */ 37 | const sleep = async (timeout) => { 38 | return new Promise((resolve) => setTimeout(resolve, timeout)); 39 | }; 40 | 41 | const palette = " ·+*&#@"; 42 | 43 | /** 44 | * @typedef {(x: number, y: number, time: number) => number} PixelFunc 45 | */ 46 | 47 | /** 48 | * Build a 2D character array. 49 | * @param width {number} 50 | * @param height {number} 51 | * @param pixelFunc {PixelFunc} 52 | * @returns {string[][]} 53 | */ 54 | const buildBuffer = (width, height, pixelFunc) => { 55 | const timestamp = new Date().getTime(); 56 | const buffer = []; 57 | 58 | for (let y = 0; y < height; y++) { 59 | const line = []; 60 | 61 | for (let x = 0; x < width; x++) { 62 | const lightness = pixelFunc(x / width, y / height, timestamp / 1000); 63 | const clamped = Math.max(0, Math.min(0.99, lightness)); 64 | const char = palette[Math.floor(clamped * palette.length)]; 65 | line.push(char ?? " "); 66 | } 67 | 68 | buffer.push(line); 69 | } 70 | 71 | return buffer; 72 | }; 73 | 74 | /** 75 | * Render the shader to the screen. 76 | * @param lastBuffer {string[][]|null} 77 | * @param width {number} 78 | * @param height {number} 79 | * @param pixelFunc {PixelFunc} 80 | */ 81 | const renderFrame = async (lastBuffer, width, height, pixelFunc) => { 82 | const buffer = buildBuffer(width, height, pixelFunc); 83 | 84 | for (let y = 1; y < height; y++) { 85 | if (buffer[y] === lastBuffer?.[y]) continue; 86 | 87 | await screen(cursorTo, 0, y); 88 | await write(buffer[y].join("")); 89 | } 90 | 91 | return buffer; 92 | }; 93 | 94 | /** 95 | * Render the status to the screen. 96 | * @param statusLine {string} 97 | * @param width {number} 98 | */ 99 | const renderStatus = async (statusLine, width) => { 100 | // Status line goes at the top of the screen. 101 | await screen(cursorTo, 0, 0); 102 | await write(statusLine.slice(0, width).padEnd(width)); 103 | }; 104 | 105 | const main = async () => { 106 | let [_node, _mjs, shaderPath, ..._rest] = process.argv; 107 | 108 | if (!shaderPath) { 109 | console.error("Usage: node render.mjs "); 110 | return; 111 | } 112 | 113 | if (!shaderPath.startsWith("./") && !shaderPath.startsWith("/")) { 114 | shaderPath = `./${shaderPath}`; 115 | } 116 | 117 | let width = process.stdout.columns ?? 80; 118 | let height = process.stdout.rows ?? 40; 119 | 120 | process.stdout.on("resize", async () => { 121 | width = process.stdout.columns; 122 | height = process.stdout.rows; 123 | 124 | await reset(); 125 | lastBuffer = null; 126 | }); 127 | 128 | let shaderModule = await import(shaderPath); 129 | 130 | let lastBuffer = null; 131 | let reloadCount = 0; 132 | let statusLine = "Loaded successfully!"; 133 | 134 | fs.watchFile(shaderPath, { interval: 250 }, async () => { 135 | try { 136 | shaderModule = await import(`${shaderPath}?reloadCount=${++reloadCount}`); 137 | statusLine = "Reloaded successfully!"; 138 | } catch (e) { 139 | statusLine = e.toString(); 140 | } 141 | }); 142 | 143 | await reset(); 144 | 145 | try { 146 | shaderModule.fragment(0, 0, 0); 147 | } catch (e) { 148 | console.error(e); 149 | process.exit(1); 150 | } 151 | 152 | while (true) { 153 | try { 154 | lastBuffer = await renderFrame( 155 | lastBuffer, 156 | width, 157 | height, 158 | shaderModule.fragment 159 | ); 160 | } catch (e) { 161 | statusLine = e.toString(); 162 | } 163 | 164 | await renderStatus(statusLine, width); 165 | await sleep(10); 166 | } 167 | }; 168 | 169 | main().catch((e) => { 170 | console.error(e.toString()); 171 | process.exit(1); 172 | }); 173 | -------------------------------------------------------------------------------- /run: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SHADER="$1" 4 | node render.mjs "$SHADER" 5 | -------------------------------------------------------------------------------- /shaderLib.mjs: -------------------------------------------------------------------------------- 1 | const sumReducer = (sum, x) => sum + x; 2 | 3 | const sin = Math.sin, 4 | cos = Math.cos, 5 | sqrt = Math.sqrt; 6 | 7 | /** 8 | * Cartesian distance between two points. 9 | * @param point0 {number[]} 10 | * @param point1 {number[]} 11 | * @returns {number} 12 | */ 13 | export const distance = (point0, point1) => { 14 | if (point0.length !== point1.length) 15 | throw new Error("Dimensionality must match"); 16 | 17 | return sqrt( 18 | point0 19 | .map((dim0, index) => Math.pow(point1[index] - dim0, 2)) 20 | .reduce(sumReducer, 0) 21 | ); 22 | }; 23 | 24 | /** 25 | * Get the magnitude of a vector. 26 | * @param vector {number[]} 27 | * @returns {number} 28 | */ 29 | export const magnitude = (vector) => { 30 | return sqrt(vector.map((dim) => dim * dim).reduce(sumReducer, 0)); 31 | }; 32 | 33 | /** 34 | * Normalize a vector 35 | * @param vector {number[]} 36 | * @returns {number[]} 37 | */ 38 | export const normalize = (vector) => { 39 | const mag = magnitude(vector); 40 | return vector.map((dim) => dim / mag); 41 | }; 42 | 43 | /** 44 | * Apply a function to elements from both vectors. 45 | * @param vector0 {number[]} 46 | * @param vector1 {number[]}number} 47 | * @param operation {(number, number) => number} 48 | * @returns {number[]} 49 | */ 50 | const vectorApply = (vector0, vector1, operation) => { 51 | return vector0.map((dim0, index) => 52 | operation(dim0, vector1 instanceof Array ? vector1[index] : vector1) 53 | ); 54 | }; 55 | 56 | export const add = (vector0, vector1) => 57 | vectorApply(vector0, vector1, (a, b) => a + b); 58 | 59 | export const sub = (vector0, vector1) => 60 | vectorApply(vector0, vector1, (a, b) => a - b); 61 | 62 | export const div = (vector0, vector1) => 63 | vectorApply(vector0, vector1, (a, b) => a / b); 64 | 65 | export const mul = (vector0, vector1) => 66 | vectorApply(vector0, vector1, (a, b) => a * b); 67 | 68 | export const max = (vector0, vector1) => 69 | vectorApply(vector0, vector1, (a, b) => (a > b ? a : b)); 70 | 71 | export const min = (vector0, vector1) => 72 | vectorApply(vector0, vector1, (a, b) => (a < b ? a : b)); 73 | 74 | export const dot = (vector0, vector1) => 75 | vectorApply(vector0, vector1, (a, b) => a * b).reduce(sumReducer, 0); 76 | 77 | export const abs = (vector) => { 78 | return vector.map((dim) => Math.abs(dim)); 79 | }; 80 | 81 | export const pow = (vector, power) => { 82 | return vector.map((dim) => Math.pow(dim, power)); 83 | }; 84 | 85 | /** 86 | * Rotate a 3D vector around the X axis. 87 | * @param param0 {[number, number, number]} 88 | * @param theta {number} 89 | * @returns {[number, number, number]} 90 | */ 91 | export const rotate3DX = ([x, y, z], theta) => { 92 | // prettier-ignore 93 | return [ 94 | x, 95 | y * cos(theta) - z * sin(theta), 96 | y * sin(theta) + z * cos(theta), 97 | ]; 98 | }; 99 | 100 | /** 101 | * Rotate a 3D vector around the Y axis. 102 | * @param param0 {[number, number, number]} 103 | * @param theta {number} 104 | * @returns {[number, number, number]} 105 | */ 106 | export const rotate3DY = ([x, y, z], theta) => { 107 | // prettier-ignore 108 | return [ 109 | x * cos(theta) + z * sin(theta), 110 | y, 111 | -x * sin(theta) + z * cos(theta), 112 | ]; 113 | }; 114 | 115 | /** 116 | * Rotate a 3D vector around the Z axis. 117 | * @param param0 {[number, number, number]} 118 | * @param theta {number} 119 | * @returns {[number, number, number]} 120 | */ 121 | export const rotate3DZ = ([x, y, z], theta) => { 122 | // prettier-ignore 123 | return [ 124 | x * cos(theta) - y * sin(theta), 125 | x * sin(theta) + y * cos(theta), 126 | z, 127 | ]; 128 | }; 129 | -------------------------------------------------------------------------------- /spiral.mjs: -------------------------------------------------------------------------------- 1 | import { distance } from "./shaderLib.mjs"; 2 | 3 | /** 4 | * PixelFunc that renders an animated spiral. 5 | * @param x {number} 6 | * @param y {number} 7 | * @param time {number} 8 | * @returns {string} 9 | */ 10 | export const fragment = (x, y, time) => { 11 | const distFromCenter = distance([x, y], [0.5, 0.5]); 12 | const angle = Math.atan2(y - 0.5, x - 0.5) + time; 13 | 14 | const bandSize = 0.2; 15 | // prettier-ignore 16 | const bandLocation = ((distFromCenter + bandSize * (angle / 2 * Math.PI)) / bandSize) % 2; 17 | if (bandLocation >= 1) return 0; 18 | 19 | return bandLocation; 20 | }; 21 | --------------------------------------------------------------------------------