UNPKG

@mesmotronic/three-retropass

Version:

RetroPass applies a retro aesthetic to your Three.js project, emulating the visual style of classic 8-bit and 16-bit games

353 lines (352 loc) 18.1 kB
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) { if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); }; var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) { if (kind === "m") throw new TypeError("Private method is not writable"); if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it"); return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value; }; var _RetroPassNode_instances, _RetroPassNode_colorPalette, _RetroPassNode_autoDitheringOffset, _RetroPassNode_autoResolution, _RetroPassNode_pixelRatio, _RetroPassNode_size, _RetroPassNode_updateResolution, _RetroPassNode_updateDitheringOffset, _RetroPassNode_setColorPalette; import * as THREE from 'three'; import { clamp, convertToTexture, float, floor, Fn, If, int, Loop, nodeObject, not, texture as tslTexture, uniform, uv, vec2, vec3, vec4 } from 'three/tsl'; import { TempNode } from 'three/webgpu'; import { createColorPalette } from '../../utils/createColorPalette'; import { createColorTexture } from '../../utils/createColorTexture'; import { isValidColorCount } from '../../utils/isValidColorCount'; /** * TSL node that applies a retro-style post-processing effect with * pixelation, color quantization, and ordered (Bayer) dithering. * * Designed for use with Three.js WebGPURenderer and PostProcessing. * * @augments TempNode * @example * import { pass } from 'three/tsl'; * import { retroPass } from '@mesmotronic/three-retropass/webgpu'; * * const scenePass = pass(scene, camera); * const postProcessing = new PostProcessing(renderer); * postProcessing.outputNode = retroPass(scenePass.getTextureNode(), { colorCount: 16 }); */ export class RetroPassNode extends TempNode { static get type() { return 'RetroPassNode'; } /** * @param textureNode - The texture node that provides the input image * @param parameters - Configuration parameters for the retro effect */ constructor(textureNode, { colorCount = 16, colorPalette, dithering = true, ditheringOffset = 0.2, autoDitheringOffset = false, pixelRatio = 0.25, resolution = new THREE.Vector2(320, 200), autoResolution = false, } = {}) { super('vec4'); _RetroPassNode_instances.add(this); /** Input texture node (the scene render) */ Object.defineProperty(this, "textureNode", { enumerable: true, configurable: true, writable: true, value: void 0 }); /** @private Uniform for retro resolution (e.g. 320×200) */ Object.defineProperty(this, "_resolution", { enumerable: true, configurable: true, writable: true, value: uniform(new THREE.Vector2(320, 200)) }); /** @private Uniform for dithering on/off */ Object.defineProperty(this, "_dithering", { enumerable: true, configurable: true, writable: true, value: uniform(true) }); /** @private Uniform for dithering strength */ Object.defineProperty(this, "_ditheringOffset", { enumerable: true, configurable: true, writable: true, value: uniform(0.2) }); /** @private Uniform for number of colors */ Object.defineProperty(this, "_colorCount", { enumerable: true, configurable: true, writable: true, value: uniform(16) }); /** @private Uniform for whether this is a quantized (cube) palette */ Object.defineProperty(this, "_isQuantized", { enumerable: true, configurable: true, writable: true, value: uniform(true) }); /** @private TextureNode for the palette */ Object.defineProperty(this, "_colorTextureNode", { enumerable: true, configurable: true, writable: true, value: tslTexture(createColorTexture(createColorPalette(16))) }); /** @private Uniform for whether to invert the image */ Object.defineProperty(this, "_inverted", { enumerable: true, configurable: true, writable: true, value: uniform(false) }); // Internal state not exposed via uniforms _RetroPassNode_colorPalette.set(this, void 0); _RetroPassNode_autoDitheringOffset.set(this, false); _RetroPassNode_autoResolution.set(this, false); _RetroPassNode_pixelRatio.set(this, 0.25); _RetroPassNode_size.set(this, new THREE.Vector2()); this.textureNode = textureNode; // Apply initial settings via setters so validation runs this.dithering = dithering; this.ditheringOffset = ditheringOffset; this.autoDitheringOffset = autoDitheringOffset; this.pixelRatio = pixelRatio; this.resolution = resolution; this.autoResolution = autoResolution; if (colorPalette) { this.colorPalette = colorPalette; } else { this.colorCount = colorCount; } } // ─── Resolution ────────────────────────────────────────────────────────── get resolution() { return this._resolution.value; } set resolution(value) { if (!value.equals(this._resolution.value)) { this._resolution.value.copy(value); } } get autoResolution() { return __classPrivateFieldGet(this, _RetroPassNode_autoResolution, "f"); } set autoResolution(value) { if (__classPrivateFieldGet(this, _RetroPassNode_autoResolution, "f") !== value) { __classPrivateFieldSet(this, _RetroPassNode_autoResolution, value, "f"); __classPrivateFieldGet(this, _RetroPassNode_instances, "m", _RetroPassNode_updateResolution).call(this); } } get pixelRatio() { return __classPrivateFieldGet(this, _RetroPassNode_pixelRatio, "f"); } set pixelRatio(value) { if (__classPrivateFieldGet(this, _RetroPassNode_pixelRatio, "f") !== value) { __classPrivateFieldSet(this, _RetroPassNode_pixelRatio, value, "f"); __classPrivateFieldGet(this, _RetroPassNode_instances, "m", _RetroPassNode_updateResolution).call(this); } } // ─── Color ─────────────────────────────────────────────────────────────── get colorCount() { return this._colorCount.value; } set colorCount(value) { if (value !== this.colorCount) { if (!isValidColorCount(value)) { throw new Error(`Invalid colorCount, must be between 2 and 4096`); } this._isQuantized.value = true; __classPrivateFieldGet(this, _RetroPassNode_instances, "m", _RetroPassNode_setColorPalette).call(this, createColorPalette(value)); } } get colorPalette() { return __classPrivateFieldGet(this, _RetroPassNode_colorPalette, "f"); } set colorPalette(colors) { const count = colors?.length; if (!isValidColorCount(count)) { throw new Error(`Invalid colorPalette, must contain between 2 and 4096 colours`); } this._isQuantized.value = false; __classPrivateFieldGet(this, _RetroPassNode_instances, "m", _RetroPassNode_setColorPalette).call(this, colors); } // ─── Dithering ─────────────────────────────────────────────────────────── get dithering() { return this._dithering.value; } set dithering(value) { this._dithering.value = value; } get ditheringOffset() { return this._ditheringOffset.value; } set ditheringOffset(value) { this._ditheringOffset.value = value; } get autoDitheringOffset() { return __classPrivateFieldGet(this, _RetroPassNode_autoDitheringOffset, "f"); } set autoDitheringOffset(value) { if (__classPrivateFieldGet(this, _RetroPassNode_autoDitheringOffset, "f") !== value) { __classPrivateFieldSet(this, _RetroPassNode_autoDitheringOffset, value, "f"); if (value) { __classPrivateFieldGet(this, _RetroPassNode_instances, "m", _RetroPassNode_updateDitheringOffset).call(this); } } } // ─── Invert ────────────────────────────────────────────────────────────── get inverted() { return this._inverted.value; } set inverted(value) { this._inverted.value = value; } // ─── Size (called by PostProcessing / EffectComposer equivalent) ────────── /** * Called by the renderer / PostProcessing pipeline when the output size * changes. Mirrors the `setSize` API from the WebGL ShaderPass. */ setSize(width, height) { __classPrivateFieldGet(this, _RetroPassNode_size, "f").set(width, height); __classPrivateFieldGet(this, _RetroPassNode_instances, "m", _RetroPassNode_updateResolution).call(this); } // ─── TSL node graph ────────────────────────────────────────────────────── /** * Builds the TSL node graph implementing the retro effect. */ setup( /* builder */) { const textureNode = this.textureNode; const uvNode = textureNode.uvNode ? vec2(textureNode.uvNode.xy) : nodeObject(uv()); const uResolution = this._resolution; const uDithering = this._dithering; const uDitheringOffset = this._ditheringOffset; const uColorCount = this._colorCount; const uColorTextureNode = this._colorTextureNode; const uIsQuantized = this._isQuantized; const uInverted = this._inverted; // ── Bayer 4×4 ordered-dither matrix ────────────────────────────────── // 16 entries encoded as four vec4 rows; indexed by (iy * 4 + ix). const bayer = [ vec4(0.0 / 16.0, 8.0 / 16.0, 2.0 / 16.0, 10.0 / 16.0), vec4(12.0 / 16.0, 4.0 / 16.0, 14.0 / 16.0, 6.0 / 16.0), vec4(3.0 / 16.0, 11.0 / 16.0, 1.0 / 16.0, 9.0 / 16.0), vec4(15.0 / 16.0, 7.0 / 16.0, 13.0 / 16.0, 5.0 / 16.0), ]; // Look up a Bayer value by integer (ix, iy) coordinates in [0,3] const bayerLookup = Fn(([ix, iy]) => { const result = float(0).toVar(); If(iy.equal(int(0)), () => { If(ix.equal(int(0)), () => { result.assign(bayer[0].x); }) .ElseIf(ix.equal(int(1)), () => { result.assign(bayer[0].y); }) .ElseIf(ix.equal(int(2)), () => { result.assign(bayer[0].z); }) .Else(() => { result.assign(bayer[0].w); }); }).ElseIf(iy.equal(int(1)), () => { If(ix.equal(int(0)), () => { result.assign(bayer[1].x); }) .ElseIf(ix.equal(int(1)), () => { result.assign(bayer[1].y); }) .ElseIf(ix.equal(int(2)), () => { result.assign(bayer[1].z); }) .Else(() => { result.assign(bayer[1].w); }); }).ElseIf(iy.equal(int(2)), () => { If(ix.equal(int(0)), () => { result.assign(bayer[2].x); }) .ElseIf(ix.equal(int(1)), () => { result.assign(bayer[2].y); }) .ElseIf(ix.equal(int(2)), () => { result.assign(bayer[2].z); }) .Else(() => { result.assign(bayer[2].w); }); }).Else(() => { If(ix.equal(int(0)), () => { result.assign(bayer[3].x); }) .ElseIf(ix.equal(int(1)), () => { result.assign(bayer[3].y); }) .ElseIf(ix.equal(int(2)), () => { result.assign(bayer[3].z); }) .Else(() => { result.assign(bayer[3].w); }); }); return result; }); // Fast cube-quantization path for large auto-generated palettes (≥27 colors) const quantizeCube = Fn(([c, colorCount]) => { const stepsF = floor(float(colorCount).pow(float(1.0 / 3.0))); const maxIdx = stepsF.sub(float(1.0)); const r = floor(c.x.mul(maxIdx).add(0.5)).div(maxIdx); const g = floor(c.y.mul(maxIdx).add(0.5)).div(maxIdx); const b = floor(c.z.mul(maxIdx).add(0.5)).div(maxIdx); return vec3(r, g, b); }); // Main retro effect const retroEffect = Fn(() => { // 1. Pixelation – snap UV to the retro grid const retroUV = floor(uvNode.mul(uResolution)).add(vec2(0.5, 0.5)).div(uResolution); const retroCoord = floor(uvNode.mul(uResolution)); // 2. Sample scene colour, optionally invert const c = vec3(textureNode.sample(retroUV).rgb).toVar(); If(uInverted, () => { c.assign(vec3(1.0).sub(c)); }); // 3. Ordered (Bayer) dithering — skip for pure black pixels If(uDithering, () => { If(c.x.greaterThan(0.0).or(c.y.greaterThan(0.0)).or(c.z.greaterThan(0.0)), () => { const ix = int(retroCoord.x.mod(4.0)); const iy = int(retroCoord.y.mod(4.0)); const bayerVal = bayerLookup(ix, iy); const offset = bayerVal.sub(0.5).mul(uDitheringOffset); c.assign(clamp(c.add(vec3(offset, offset, offset)), vec3(0.0), vec3(1.0))); }); }); // 4. Color quantization const closestColor = vec3(0.0).toVar(); If(not(uIsQuantized).or(uColorCount.lessThan(int(27))), () => { // Brute-force search — always correct for small / explicit palettes const minDist = float(1e6).toVar(); Loop({ start: int(0), end: int(uColorCount) }, ({ i }) => { const u = float(i).add(0.5).div(float(uColorCount)); const paletteColor = vec3(uColorTextureNode.sample(vec2(u, 0.5)).rgb); const dist = c.distance(paletteColor); If(dist.lessThan(minDist), () => { minDist.assign(dist); closestColor.assign(paletteColor); }); }); }).Else(() => { // Fast cube quantization for large auto-palettes closestColor.assign(quantizeCube(c, uColorCount)); }); return vec4(closestColor, float(1.0)); }); return retroEffect(); } } _RetroPassNode_colorPalette = new WeakMap(), _RetroPassNode_autoDitheringOffset = new WeakMap(), _RetroPassNode_autoResolution = new WeakMap(), _RetroPassNode_pixelRatio = new WeakMap(), _RetroPassNode_size = new WeakMap(), _RetroPassNode_instances = new WeakSet(), _RetroPassNode_updateResolution = function _RetroPassNode_updateResolution() { if (__classPrivateFieldGet(this, _RetroPassNode_autoResolution, "f")) { this._resolution.value.set(__classPrivateFieldGet(this, _RetroPassNode_size, "f").x * __classPrivateFieldGet(this, _RetroPassNode_pixelRatio, "f"), __classPrivateFieldGet(this, _RetroPassNode_size, "f").y * __classPrivateFieldGet(this, _RetroPassNode_pixelRatio, "f")); } }, _RetroPassNode_updateDitheringOffset = function _RetroPassNode_updateDitheringOffset() { if (__classPrivateFieldGet(this, _RetroPassNode_autoDitheringOffset, "f")) { this._ditheringOffset.value = 0.03 + 0.97 / (this.colorCount - 1); } }, _RetroPassNode_setColorPalette = function _RetroPassNode_setColorPalette(colors) { const count = colors.length; const newTex = createColorTexture(colors); this._colorCount.value = count; this._colorTextureNode.value?.dispose(); this._colorTextureNode.value = newTex; if (__classPrivateFieldGet(this, _RetroPassNode_autoDitheringOffset, "f")) { __classPrivateFieldGet(this, _RetroPassNode_instances, "m", _RetroPassNode_updateDitheringOffset).call(this); } __classPrivateFieldSet(this, _RetroPassNode_colorPalette, colors.slice(), "f"); }; /** * TSL convenience function for creating a RetroPassNode. * * @tsl * @function * @param node - Input node (scene render texture or any vec4 node) * @param parameters - Retro effect configuration * @returns A node-wrapped RetroPassNode ready for use in PostProcessing * * @example * import { pass } from 'three/tsl'; * import { retroPass } from '@mesmotronic/three-retropass/webgpu'; * * const scenePass = pass(scene, camera); * postProcessing.outputNode = retroPass(scenePass.getTextureNode(), { * colorCount: 4, * dithering: true, * }); */ export const retroPass = (node, parameters) => nodeObject(new RetroPassNode(convertToTexture(node), parameters)); export default RetroPassNode;