diff --git a/bun.lock b/bun.lock index 02a375b95..63affbc27 100644 --- a/bun.lock +++ b/bun.lock @@ -2,11 +2,14 @@ "lockfileVersion": 1, "workspaces": { "": { - "name": "opentui", + "name": "@opentui/core", "dependencies": { + "@typegpu/noise": "^0.1.0", "bun-webgpu": "0.1.0", "jimp": "1.6.0", "three": "0.177.0", + "typegpu": "^0.7.0", + "unplugin-typegpu": "^0.2.2", "yoga-layout": "3.2.1", }, "devDependencies": { @@ -23,6 +26,8 @@ }, }, "packages": { + "@babel/standalone": ["@babel/standalone@7.28.2", "", {}, "sha512-1kjA8XzBRN68HoDDYKP38bucHtxYWCIX8XdYwe1drRNUOjOVNt8EMy9jiE6UwaGFfU7NOHCG+C8KgBc9CR08nA=="], + "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], @@ -83,15 +88,21 @@ "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], + "@typegpu/noise": ["@typegpu/noise@0.1.0", "", { "peerDependencies": { "typegpu": "^0.6.0" } }, "sha512-94CQfZhsszv/FEsKdhswa5yWTM0VQIuum4myNuPSDyHKyJfE86xnJPF8LvNPhmYOPkMpY2czyKFVh0CG90fEfw=="], + "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], - "@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], + + "@types/react": ["@types/react@19.1.9", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="], "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], @@ -103,6 +114,8 @@ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], @@ -121,6 +134,10 @@ "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], @@ -141,6 +158,10 @@ "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + + "magic-string-ast": ["magic-string-ast@1.0.0", "", { "dependencies": { "magic-string": "^0.30.17" } }, "sha512-8rbuNizut2gW94kv7pqgt0dvk+AHLPVIm0iJtpSgQJ9dx21eWx5SBel8z3jp1xtC0j6/iyK3AWGhAR1H61s7LA=="], + "meshoptimizer": ["meshoptimizer@0.18.1", "", {}, "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw=="], "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], @@ -155,8 +176,12 @@ "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], "planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="], @@ -187,12 +212,28 @@ "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + "tinyest": ["tinyest@0.1.1", "", {}, "sha512-YNHlB8BOXgW6RPzrfqqAkgyY9xj33sjXJcJlOl3MwY0BXXx26m3JUqf5yV8iBdwJPNe51DmxypR9Zbbd266biQ=="], + + "tinyest-for-wgsl": ["tinyest-for-wgsl@0.1.2", "", { "dependencies": { "tinyest": "~0.1.1" } }, "sha512-yJ49SoJIpEi4ADsBVNE54GVJ5JZMIAKNkRueeNpYhIiq0z1Nn9THJNMNP1b9HI0VQt7LzCrxT0ZP29muiUtcRg=="], + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "typed-binary": ["typed-binary@4.3.2", "", {}, "sha512-HT3pIBM2njCZUmeczDaQUUErGiM6GXFCqMsHegE12HCoBtvHCkfR10JJni0TeGOTnLilTd6YFyj+YhflqQDrDQ=="], + + "typegpu": ["typegpu@0.7.0", "", { "dependencies": { "tinyest": "~0.1.1", "typed-binary": "^4.3.1" } }, "sha512-BueQ/74zgUCwqg/nmSxZ2aL7NFnv3jzANlUmgyKbGtKg0jkCu9kUAVtPjwdjWDmlah5WlzAAXi7qD5URlU6wtA=="], + + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + + "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + + "unplugin": ["unplugin@2.3.5", "", { "dependencies": { "acorn": "^8.14.1", "picomatch": "^4.0.2", "webpack-virtual-modules": "^0.6.2" } }, "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw=="], + + "unplugin-typegpu": ["unplugin-typegpu@0.2.2", "", { "dependencies": { "@babel/standalone": "^7.27.0", "defu": "^6.1.4", "estree-walker": "^3.0.3", "magic-string-ast": "^1.0.0", "pathe": "^2.0.3", "picomatch": "^4.0.3", "tinyest": "~0.1.1", "tinyest-for-wgsl": "~0.1.2", "unplugin": "^2.3.5" }, "peerDependencies": { "typegpu": "^0.7.0" } }, "sha512-5pbwv0cTMRMxRCQXEPAMUsUoAKMQsz2B4zlhpRRHNy3qtMk+rP3Uxe/5zZUtr2yAASCVO2iciblH5HqcUoijjQ=="], "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], @@ -203,6 +244,8 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], } } diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 000000000..7d029e77c --- /dev/null +++ b/bunfig.toml @@ -0,0 +1 @@ +preload = ["./preload.ts"] diff --git a/package.json b/package.json index e2d780f2f..155182889 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,12 @@ "printWidth": 120 }, "dependencies": { + "@typegpu/noise": "^0.1.0", "bun-webgpu": "0.1.0", "jimp": "1.6.0", "three": "0.177.0", + "typegpu": "^0.7.0", + "unplugin-typegpu": "^0.2.2", "yoga-layout": "3.2.1" } } diff --git a/preload.ts b/preload.ts new file mode 100644 index 000000000..e31488ffc --- /dev/null +++ b/preload.ts @@ -0,0 +1,8 @@ +import { plugin } from "bun" +import typegpu from "unplugin-typegpu/bun" + +plugin( + typegpu({ + include: /\.ts$/, + }), +) diff --git a/src/examples/caustics-demo.ts b/src/examples/caustics-demo.ts new file mode 100644 index 000000000..703c4055a --- /dev/null +++ b/src/examples/caustics-demo.ts @@ -0,0 +1,231 @@ +#!/usr/bin/env bun + +import { perlin3d } from "@typegpu/noise" +import { createWebGPUDevice, setupGlobals } from "bun-webgpu" +import tgpu, { type TgpuRoot } from "typegpu" +import * as d from "typegpu/data" +import * as std from "typegpu/std" +import { CLICanvas, type CliRenderer, GroupRenderable, SuperSampleType } from "../index" + +/** With supersampling, the scene is rendered at 2x the resolution */ +const pixelRatio = 2 +/** Controls the angle of rotation for the pool tile texture */ +const angle = 0.2 +/** The scene fades into this color at a distance */ +const fogColor = d.vec3f(0.05, 0.2, 0.7) +/** The ambient light color */ +const ambientColor = d.vec3f(0.2, 0.5, 1) +/** Color tint of the god rays */ +const godRayTint = d.vec3f(0.18, 0.3, 0.5) +const tileDensity = 3 + +const layout = tgpu.bindGroupLayout({ + aspect: { uniform: d.f32 }, + time: { uniform: d.f32 }, +}) + +const mainVertex = tgpu["~unstable"].vertexFn({ + in: { vertexIndex: d.builtin.vertexIndex }, + out: { pos: d.builtin.position, uv: d.vec2f }, +})(({ vertexIndex }) => { + const pos = [d.vec2f(-1, -1), d.vec2f(3, -1), d.vec2f(-1, 3)] + const left = 0.5 - layout.$.aspect * 0.5 + const right = 0.5 + layout.$.aspect * 0.5 + const uv = [d.vec2f(left, 0), d.vec2f(right, 0), d.vec2f(left, 2)] + + return { + pos: d.vec4f(pos[vertexIndex], 0, 1), + uv: uv[vertexIndex], + } +}) + +/** + * Given a coordinate, it returns a grayscale floor tile pattern at that + * location. + */ +const tilePattern = tgpu.fn( + [d.vec2f], + d.f32, +)((uv) => { + const tiledUv = std.fract(uv) + const proximity = std.abs(tiledUv.mul(2).sub(1)) + const maxProximity = std.max(proximity.x, proximity.y) + return std.clamp(std.pow(1 - maxProximity, 0.8) * 5, 0, 1) +}) + +const caustics = tgpu.fn( + [d.vec2f, d.f32, d.vec3f], + d.vec3f, +)((uv, time, profile) => { + const distortion = perlin3d.sample(d.vec3f(uv.mul(0.5), time * 0.2)) + // Distorting UV coordinates + const uv2 = uv.add(distortion) + const noise = std.abs(perlin3d.sample(d.vec3f(uv2.mul(5), time))) + return std.pow(d.vec3f(1 - noise), profile) +}) + +/** + * Returns a transformation matrix that represents an `angle` rotation + * in the XY plane (around the imaginary Z axis) + */ +const rotateXY = tgpu.fn( + [d.f32], + d.mat2x2f, +)((angle) => { + return d.mat2x2f( + /* right */ d.vec2f(std.cos(angle), std.sin(angle)), + /* up */ d.vec2f(-std.sin(angle), std.cos(angle)), + ) +}) + +const mainFragment = tgpu["~unstable"].fragmentFn({ + in: { uv: d.vec2f }, + out: d.vec4f, +})(({ uv }) => { + const time = layout.$.time + /** + * A transformation matrix that skews the perspective a bit + * when applied to UV coordinates + */ + const skewMat = d.mat2x2f( + d.vec2f(std.cos(angle), std.sin(angle)), + d.vec2f(-std.sin(angle) * 5 + uv.x * 2, std.cos(angle) * 5), + ) + const skewedUv = skewMat.mul(uv) + const tile = tilePattern(skewedUv.mul(tileDensity)) + const albedo = std.mix(d.vec3f(0.1), d.vec3f(1), tile) + + // Transforming coordinates to simulate perspective squash + const cuv = d.vec2f(uv.x * (std.pow(uv.y * 1.5, 3) + 0.1) * 5, std.pow((uv.y * 1.5 + 0.1) * 1.5, 3) * 1) + // Generating two layers of caustics (large scale, and small scale) + const c1 = caustics(cuv, time * 0.2, d.vec3f(4, 4, 1)) + // Tinting + .mul(d.vec3f(0.4, 0.65, 1)) + const c2 = caustics(cuv.mul(2), time * 0.4, d.vec3f(16, 1, 4)) + // Tinting + .mul(d.vec3f(0.18, 0.3, 0.5)) + + // -- BLEND -- + + const blendCoord = d.vec3f(uv.mul(d.vec2f(5, 10)), layout.$.time * 0.2 + 5) + // A smooth blending factor, so that caustics only appear at certain spots + const blend = std.clamp(perlin3d.sample(blendCoord) + 0.3, 0, 1) + + // -- FOG -- + + const noFogColor = albedo.mul(std.mix(ambientColor, c1.add(c2), blend)) + // Fog blending factor, based on the height of the pixels + const fog = std.min(std.pow(uv.y, 0.5) * 1.2, 1) + + // -- GOD RAYS -- + + const godRayUv = rotateXY(-0.3).mul(uv).mul(d.vec2f(10, 2)) + const godRay1 = perlin3d.sample(d.vec3f(godRayUv, time * 0.5)) + 1 + const godRay2 = perlin3d.sample(d.vec3f(godRayUv.mul(2), time * 0.3)) + 1 + const godRayBlend = std.pow(uv.y, 2) * 0.5 + const godRays = godRayTint.mul(godRay1 + godRay2).mul(godRayBlend * 0.6) + + return d.vec4f(std.mix(noFogColor, fogColor, fog).add(godRays), 1) +}) + +let isRunning = true +let root: TgpuRoot | undefined +let keyHandler: ((key: Buffer) => void) | undefined +let handleResize: ((width: number, height: number) => void) | undefined +let parentContainer: GroupRenderable | undefined + +export async function run(renderer: CliRenderer): Promise { + isRunning = true + renderer.start() + const WIDTH = renderer.terminalWidth + const HEIGHT = renderer.terminalHeight + + parentContainer = new GroupRenderable("shader-container", { + x: 0, + y: 0, + zIndex: 10, + visible: true, + }) + renderer.add(parentContainer) + + // Bun WebGPU setup + setupGlobals() + const device = await createWebGPUDevice() + const canvas = new CLICanvas(device, WIDTH * pixelRatio, HEIGHT * pixelRatio, SuperSampleType.GPU) + + root = tgpu.initFromDevice({ device }) + + /** Seconds passed since the start of the example, wrapped to the range [0, 1000) */ + const timeBuffer = root.createBuffer(d.f32).$usage("uniform") + /** Aspect ratio of the canvas */ + const aspectBuffer = root.createBuffer(d.f32, WIDTH / HEIGHT).$usage("uniform") + + const bindGroup = root.createBindGroup(layout, { + time: timeBuffer, + aspect: aspectBuffer, + }) + + handleResize = (width: number, height: number) => { + aspectBuffer.write(width / height) + canvas.setSize(width * pixelRatio, height * pixelRatio) + } + + renderer.on("resize", handleResize) + + // Assuming a format... + const presentationFormat = "rgba8unorm" as const + const context = canvas.getContext("webgpu") as GPUCanvasContext + + context.configure({ + device: root.device, + format: presentationFormat, + alphaMode: "premultiplied", + }) + + const pipeline = root["~unstable"] + .withVertex(mainVertex, {}) + .withFragment(mainFragment, { format: presentationFormat }) + .createPipeline() + // --- + .with(layout, bindGroup) + + let time = 0 + + renderer.setFrameCallback(async (deltaMs) => { + if (!isRunning) return + + time += deltaMs / 1000 + timeBuffer.write(time) + + pipeline + .withColorAttachment({ + view: context.getCurrentTexture().createView(), + loadOp: "clear", + storeOp: "store", + }) + .draw(3) + + await canvas.readPixelsIntoBuffer(renderer.nextRenderBuffer) + }) +} + +export function destroy(renderer: CliRenderer): void { + isRunning = false + if (keyHandler) { + process.stdin.off("data", keyHandler) + keyHandler = undefined + } + + if (handleResize) { + renderer.off("resize", handleResize) + handleResize = undefined + } + + renderer.clearFrameCallbacks() + root?.destroy() + + if (parentContainer) { + renderer.remove("shader-container") + parentContainer = undefined + } +} diff --git a/src/examples/index.ts b/src/examples/index.ts index be5a397b7..03ac65d25 100644 --- a/src/examples/index.ts +++ b/src/examples/index.ts @@ -12,6 +12,8 @@ import { type ParsedKey, } from "../index" import { renderFontToFrameBuffer, measureText } from "../ui/ascii.font" +import * as causticsDemo from "./caustics-demo" +import * as lavalampDemo from "./lavalamp-demo" import * as boxExample from "./fonts" import * as fractalShaderExample from "./fractal-shader-demo" import * as framebufferExample from "./framebuffer-demo" @@ -164,6 +166,18 @@ const examples: Example[] = [ run: fractalShaderExample.run, destroy: fractalShaderExample.destroy, }, + { + name: "Caustics Shader", + description: "Caustics in a fragment shader", + run: causticsDemo.run, + destroy: causticsDemo.destroy, + }, + { + name: "Lava Lamp Shader", + description: "Lava Lamp effect in a fragment shader", + run: lavalampDemo.run, + destroy: lavalampDemo.destroy, + }, { name: "Phong Lighting", description: "Phong lighting model demo", diff --git a/src/examples/lavalamp-demo.ts b/src/examples/lavalamp-demo.ts new file mode 100644 index 000000000..43a4e060c --- /dev/null +++ b/src/examples/lavalamp-demo.ts @@ -0,0 +1,226 @@ +#!/usr/bin/env bun + +import { perlin3d } from "@typegpu/noise" +import { createWebGPUDevice, setupGlobals } from "bun-webgpu" +import tgpu, { type TgpuRoot } from "typegpu" +import * as d from "typegpu/data" +import { abs, mix, pow, sign, tanh } from "typegpu/std" +import { CLICanvas, type CliRenderer, GroupRenderable, SuperSampleType, TextRenderable } from "../index" + +/** The size of the perlin noise (in time), after which the pattern loops around */ +const domainDepth = 10 +/** The size of the perlin noise (in space) */ +const domainSize = 10 +/** With supersampling, the scene is rendered at 2x the resolution */ +const pixelRatio = 2 + +const fullScreenTriangle = tgpu["~unstable"].vertexFn({ + in: { vertexIndex: d.builtin.vertexIndex }, + out: { pos: d.builtin.position, uv: d.vec2f }, +})((input) => { + const pos = [d.vec2f(-1, -1), d.vec2f(3, -1), d.vec2f(-1, 3)] + + return { + pos: d.vec4f(pos[input.vertexIndex], 0.0, 1.0), + uv: pos[input.vertexIndex].mul(0.5), + } +}) + +const aspectAccess = tgpu["~unstable"].accessor(d.f32); +const timeAccess = tgpu["~unstable"].accessor(d.f32) +const sharpnessAccess = tgpu["~unstable"].accessor(d.f32) + +const exponentialSharpen = tgpu.fn( + [d.f32, d.f32], + d.f32, +)((n, sharpness) => { + return sign(n) * pow(abs(n), 1 - sharpness) +}) + +const tanhSharpen = tgpu.fn( + [d.f32, d.f32], + d.f32, +)((n, sharpness) => { + return tanh(n * (1 + sharpness * 10)) +}) + +/** The method to use for sharpening. Can be swapped at pipeline creation */ +const sharpenFnSlot = tgpu.slot(exponentialSharpen) + +const mainFragment = tgpu["~unstable"].fragmentFn({ + in: { uv: d.vec2f }, + out: d.vec4f, +})((input) => { + const uv = input.uv.mul(domainSize * 0.5).mul(d.vec2f(aspectAccess.$, 1.5)); + const n = perlin3d.sample(d.vec3f(uv, timeAccess.$ * 0.2)) + + // Apply sharpening function + const sharp = sharpenFnSlot.$(n, sharpnessAccess.$) + + // Map to 0-1 range + const n01 = sharp * 0.5 + 0.5 + + // Gradient map + const dark = d.vec3f(0, 0.2, 1) + const light = d.vec3f(1, 0.3, 0.5) + return d.vec4f(mix(dark, light, n01), 1) +}) + +let isRunning = true +let activeSharpenFn: "exponential" | "tanh" = "exponential" +let root: TgpuRoot | undefined +let keyHandler: ((key: Buffer) => void) | undefined +let handleResize: ((width: number, height: number) => void) | undefined +let parentContainer: GroupRenderable | undefined + +export async function run(renderer: CliRenderer): Promise { + isRunning = true + renderer.start() + const WIDTH = renderer.terminalWidth + const HEIGHT = renderer.terminalHeight + + parentContainer = new GroupRenderable("shader-container", { + x: 0, + y: 0, + zIndex: 10, + visible: true, + }) + renderer.add(parentContainer) + + const controlsText = new TextRenderable("demo_controls", { + content: "S: Toggle Sharpening Method | +/-: Sharpness | Escape: Back to menu", + x: 0, + y: HEIGHT - 2, + fg: "#FFFFFF", + zIndex: 20, + }) + parentContainer.add(controlsText) + + const statusText = new TextRenderable("demo_status", { + content: "Sharpening: exponential", + x: 0, + y: 0, + fg: "#FFFFFF", + zIndex: 20, + }) + parentContainer.add(statusText) + + // Bun WebGPU setup + setupGlobals() + const device = await createWebGPUDevice() + const canvas = new CLICanvas(device, WIDTH * pixelRatio, HEIGHT * pixelRatio, SuperSampleType.GPU) + + root = tgpu.initFromDevice({ device }) + // Assuming a format... + const presentationFormat = "rgba8unorm" as const + + /** Contains all resources that the perlin cache needs access to */ + const perlinCache = perlin3d.staticCache({ root, size: d.vec3u(domainSize, domainSize, domainDepth) }) + + const aspect = root.createUniform(d.f32, WIDTH / HEIGHT); + const time = root.createUniform(d.f32, 0) + const sharpness = root.createUniform(d.f32, 0.5) + + const renderPipelineBase = root["~unstable"] + .with(aspectAccess, aspect) + .with(timeAccess, time) + .with(sharpnessAccess, sharpness) + .pipe(perlinCache.inject()) + + const renderPipelines = { + exponential: renderPipelineBase + .with(sharpenFnSlot, exponentialSharpen) + .withVertex(fullScreenTriangle, {}) + .withFragment(mainFragment, { format: presentationFormat }) + .createPipeline(), + tanh: renderPipelineBase + .with(sharpenFnSlot, tanhSharpen) + .withVertex(fullScreenTriangle, {}) + .withFragment(mainFragment, { format: presentationFormat }) + .createPipeline(), + } + + handleResize = (width: number, height: number) => { + canvas.setSize(width * pixelRatio, height * pixelRatio) + aspect.write(width / height); + controlsText.y = height - 2; + } + + renderer.on("resize", handleResize) + + const context = canvas.getContext("webgpu") as GPUCanvasContext + + context.configure({ + device: root.device, + format: presentationFormat, + alphaMode: "premultiplied", + }) + + let timeAcc = 0 + let sharpnessCpu = 0.5 + + const updateStatusText = () => { + statusText.content = `Method: ${activeSharpenFn}, Sharpness: ${sharpnessCpu.toFixed(1)}` + } + updateStatusText(); + + keyHandler = (key: Buffer) => { + const keyStr = key.toString() + + if (keyStr === "s") { + activeSharpenFn = activeSharpenFn === "exponential" ? "tanh" : "exponential" + } + + if (keyStr === "+" || keyStr === "=") { + sharpnessCpu = Math.min(sharpnessCpu + 0.1, 1) + sharpness.write(sharpnessCpu) + } + + if (keyStr === "-" || keyStr === "_") { + sharpnessCpu = Math.max(sharpnessCpu - 0.1, 0) + sharpness.write(sharpnessCpu) + } + + updateStatusText() + } + + process.stdin.on("data", keyHandler) + + renderer.setFrameCallback(async (deltaMs) => { + if (!isRunning) return + + timeAcc += deltaMs / 1000 + time.write(timeAcc) + + renderPipelines[activeSharpenFn] + .withColorAttachment({ + view: context.getCurrentTexture().createView(), + loadOp: "clear", + storeOp: "store", + }) + .draw(3) + + await canvas.readPixelsIntoBuffer(renderer.nextRenderBuffer) + }) +} + +export function destroy(renderer: CliRenderer): void { + isRunning = false + if (keyHandler) { + process.stdin.off("data", keyHandler) + keyHandler = undefined + } + + if (handleResize) { + renderer.off("resize", handleResize) + handleResize = undefined + } + + renderer.clearFrameCallbacks() + root?.destroy() + + if (parentContainer) { + renderer.remove("shader-container") + parentContainer = undefined + } +}