@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
JavaScript
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;