// adapted from https://medium.com/@necsoft/three-js-101-hello-world-part-1-443207b1ebe1 let size = 128; let scale = 4; var scene = new THREE.Scene(); var camera = new THREE.PerspectiveCamera( 75, 1, 0.1, 1000 ); camera.position.z = 4; /* * we construct a canvas manually so we can specify that * we want a webgl2 context */ var canvas = document.createElement( 'canvas' ); var context = canvas.getContext( 'webgl2', { alpha: false } ); var renderer = new THREE.WebGLRenderer({antialias:false, canvas: canvas, context: context }); renderer.setSize( size, size ); document.body.appendChild( renderer.domElement ); renderer.domElement.style.imageRendering = "pixelated" renderer.domElement.style.width = (size * scale) + 'px'; renderer.domElement.style.height = (size * scale) + 'px'; var geometry = new THREE.BoxGeometry( 1, 1, 1 ); var material = new THREE.MeshLambertMaterial( { color: "#433F81" } ); var cube = new THREE.Mesh( geometry, material ); scene.add( cube ); var lineMaterial = new THREE.LineBasicMaterial({ color: "#ae3F81" }); var lineGeometry = new THREE.Geometry(); lineGeometry.vertices.push( new THREE.Vector3( -2, 0, 0 ), new THREE.Vector3( 0, 2, 0 ), new THREE.Vector3( 2, 0, 0 ), new THREE.Vector3( 0, 0, 0 ) ); var line = new THREE.Line( lineGeometry, lineMaterial ); cube.add( line ); var pointMaterial = new THREE.PointsMaterial({ color: "#43ae81", size: 1, sizeAttenuation: false }); var pointGeometry = new THREE.Geometry(); pointGeometry.vertices.push( new THREE.Vector3( -1.5, 1.5, 2 ), new THREE.Vector3( 1.5, 2, -2 ), new THREE.Vector3( 1.5, -1.5, 0 ), new THREE.Vector3( 0, 0, 0 ) ); var points = new THREE.Points( pointGeometry, pointMaterial ); cube.add( points ); var directionalLight = new THREE.DirectionalLight( 0xffffff, 0.5 ); scene.add( directionalLight ); var light = new THREE.AmbientLight( 0x404040, 5 ); scene.add( light ); /* * we have to change the way we're rendering the scene. instead of rendering * using the renderer directly, we have to use a EffectComposer so that we can * apply a shader pass. * * we call loadShaderPass (defined below) which loads a palette image asynchronously * and constructs a ShaderPass out of it with our palette shader (also defined below). * this is passed into a callback which starts rendering with an EffectComposer * * palette images need to be accessible across origins. i've uploaded a few to postimg * so you can try swapping them out and compare the effects they have on the rendered scene */ loadShaderPass( // "https://i.postimg.cc/qB3j1s6H/primal8-1x.png", // https://lospec.com/palette-list/primal8 // "https://i.postimg.cc/DzRYzB45/slso8-1x.png", // https://lospec.com/palette-list/slso8 "https://i.postimg.cc/7hSv4jBg/dynasty38-1x.png", // https://lospec.com/palette-list/dynasty38 shaderPass => { /* * the effect composer pipeline for this scene is one where we * 1. render the scene normally using a RenderPass * 2. apply the shader constructed by loadShaderPass */ let composer = new THREE.EffectComposer(renderer); let renderPass = new THREE.RenderPass(scene, camera) composer.addPass(renderPass) composer.addPass(shaderPass) /* * note that if you remove composer.addPass(shaderPass) the image * will be antialiased and blurry. you can uncomment the following line * to disable the antialiasing */ // composer.addPass(composer.copyPass) /* * the render loop is similar to the earlier ones, the difference * being the call to composer.render() */ var render = function () { requestAnimationFrame( render ); cube.rotation.x += 0.01; cube.rotation.y += 0.01; composer.render() }; render(); }) /* * takes a palette as an array of THREE.Colors and returns a THREE.ShaderMaterial * that constrains the color of every rendered pixel to be one of the colors in * the palette. every pixel is replaced with the color in the palette that is closest. * the shader will also generate a dither pattern if two palette colors are within * a threshold of ... * the shader is designed to work with EffectComposer. */ function constrainedPaletteShader(palette) { return new THREE.ShaderMaterial({ uniforms: { /* EffectComposer compatibility, the input image */ "tDiffuse": { value: null }, /* * The palette, an array of THREE.Color. you can change the palette * uniform at runtime only if the size remains the same, as the size * gets compiled into the shader. it is usually easiest to just call * constrainedPaletteShader again and get a new shader if you're changing * the palette */ "palette": { value: palette }, /* The threshold under which to perform a dither */ "threshold": {value:0.03} }, /* standard vert shader */ vertexShader: [ "out vec2 vUv;", "void main() {", "vUv = uv;", "gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );", "}" ].join( "\n" ), fragmentShader: [ "uniform vec3 palette[" + palette.length + "];", "uniform sampler2D tDiffuse;", "uniform float threshold;", "in vec2 vUv;", "void main() {", /* the input pixel */ "vec3 color = texture2D( tDiffuse, vUv ).rgb;", "float total = gl_FragCoord.x + gl_FragCoord.y;", "bool isEven = mod(total,2.0)==0.0;", "float closestDistance = 1.0;", "vec3 closestColor = palette[0];", "int firstIndex = 0;", "int secondIndex = 0;", "float secondClosestDistance = 1.0;", "vec3 secondClosestColor = palette[1];", /* * loop through the palette colors and compute the two closest colors * to the input pixel color */ "for(int i=0;i<" + palette.length +"; i++) {", "float d = distance(color, palette[i]);", "if(d <= closestDistance) {", "secondIndex = firstIndex;", "secondClosestDistance = closestDistance;", "secondClosestColor = closestColor;", "firstIndex = i;", "closestDistance = d;", "closestColor = palette[i];", "} else if (d <= secondClosestDistance) {", "secondIndex = i;", "secondClosestDistance = d;", "secondClosestColor = palette[i];", "}", "}", /* * if the two closest colors are within the threshold of each other * preform a dither */ "if(distance(closestDistance, secondClosestDistance) < threshold) {", "vec3 a = firstIndex < secondIndex ? closestColor : secondClosestColor;", "vec3 b = firstIndex < secondIndex ? secondClosestColor : closestColor;", "gl_FragColor = vec4(isEven ? a : b, 1.0);", /* otherwise use the closest color */ "} else {", "gl_FragColor = vec4(closestColor, 1);", "}", "}" ].join( "\n" ) }); } /* get the image data (pixels) from an HTML image element */ function imageData(img) { var canvas = document.createElement('canvas'); var context = canvas.getContext('2d'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; context.drawImage(img, 0, 0 ); return context.getImageData(0, 0, img.naturalWidth, img.naturalHeight); } /* get a palette (an array of THREE.Color) from an HTML image */ function palette(img) { let palette = []; let pixels = imageData(img).data for (var i = 0; i < pixels.length; i+=4) { palette.push(new THREE.Color(pixels[i] / 256.0, pixels[i+1] / 256.0, pixels[i+2] / 256.0)) } return palette; } /* * loads the image from url asynchronously and generates a palette from it * the image is expected to be 1 pixel high. a the callback cb is invoked * passing in the generated ShaderPass when everything is done. */ function loadShaderPass(url, cb) { var image = new Image(); image.crossOrigin = "Anonymous"; image.src = url; image.addEventListener('load', function () { let pass = new THREE.ShaderPass(constrainedPaletteShader(palette(image))); cb(pass) }) }
<!-- we need a few more scripts out of the three.js distribution --> <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/shaders/CopyShader.js"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/postprocessing/EffectComposer.js"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/postprocessing/RenderPass.js"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/postprocessing/ShaderPass.js"></script>
html, body { margin: 0; background: black; display: flex; justify-content: center; align-items: center; height: 100%; }