javascriptthree.jsshaderopacitydepth-testing

Three.js smooth particles opacity cut on depthTest


I have a particle system in three.js 0.173.0 with soft particle edges, and when z soring occurs the opaque pixels overwrite the scene rather than additionally drawn on the top, when the particle's order is higher than its original place in the draw order. I've tried a couple of things, double pass, different material settings... but the problem seemed to persist. I've made a codepen demonstrating the issue. Please check it out and leave a comment if you have any ideas how to resolve it.

Codepen example

threejs shader opacity cut off

import * as THREE from "https://esm.sh/three";

let camera, scene, renderer, material, points;

const vertexShader = `
attribute float u_id;
varying vec2 vUv;
varying vec3 vColor;

void main() {
    vec3 newPosition = position;

    //
    // THE CIRCLES HAVE TO BE POSITIONED IN THE SHADER
    //
    
    if (u_id == 0.0) {
        newPosition = vec3(-0.6, 0.0, 1.2);
        vColor = vec3(1.0, 0.0, 0.0);
    }
    if (u_id == 1.0) {
        newPosition = vec3(0.0, 0.0, 1.0);
        vColor = vec3(0.0, 1.0, 0.0);
    }
    if (u_id == 2.0) {
        newPosition = vec3(0.6, 0.0, 1.1);
        vColor = vec3(0.0, 0.0, 1.0);
    }
    vUv = newPosition.xy;
    gl_PointSize = 200.0; // Control the circle size
    gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
`;

const fragmentShader = `
precision mediump float;
varying vec2 vUv;
varying vec3 vColor;

void main() {
    vec2 uv = gl_PointCoord - vec2(0.5); // Center the UVs in the circle
    float dist = length(uv);
    float alpha = smoothstep(0.5, 0.1, dist); // Smooth fade at edges

    vec3 color = vColor; //vec3(1.0, 0.0, 0.0); // Red color

    if (dist > 0.5) discard; // Make the circle smooth
    gl_FragColor = vec4(color, alpha);
}
`;

const VERTEX_COUNT = 3;

// Initial vertex positions are [0, 0, 0]
const getInitialVertices = () => {
    const vertices = new Float32Array(VERTEX_COUNT * 3);
    return vertices;
};

// Generate vertex IDs
const getVertexIds = () => {
    const ids = new Float32Array(VERTEX_COUNT);
    for (let i = 0; i < VERTEX_COUNT; i++) {
        ids[i] = i;
    }
    return ids;
};

function init() {
    scene = new THREE.Scene();

    camera = new THREE.PerspectiveCamera(
        50,
        window.innerWidth / window.innerHeight,
        1,
        10000
    );
    camera.position.z = 4;
    scene.add(camera);

    const uniforms = {};

    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute("position", new THREE.BufferAttribute(getInitialVertices(), 3));
    geometry.setAttribute("u_id", new THREE.BufferAttribute(getVertexIds(), 1));

    material = new THREE.ShaderMaterial({
        uniforms: uniforms,
        vertexShader: vertexShader,
        fragmentShader: fragmentShader,
        transparent: true,
        //
        // THE OPACITY CUT OCCURES WHEN DEPTH TEST IS ON
        //
        depthTest: true,
    });

    points = new THREE.Points(geometry, material);
    scene.add(points);

    renderer = new THREE.WebGLRenderer({ alpha: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);
}

function animate() {
    requestAnimationFrame(animate);
    renderer.render(scene, camera);
}

window.addEventListener("load", () => {
    init();
    animate();
});

Solution

  • Try set

    material.depthWrite = false;
    

    Thanks to this, the particles do not save their position in the depth buffer and the transparent particles are correctly put on each other "...without creating z-index artifacts."

    https://threejs.org/docs/#api/en/materials/Material.depthWrite

    <script type="importmap">
      {
    "imports": {
      "three": "https://unpkg.com/three@0.173.0/build/three.module.js",
      "three/addons/": "https://unpkg.com/three@0.173.0/examples/jsm/"
    }
      }
    </script>
    
    <script type="module">
    import * as THREE from 'three';
    
    let camera, scene, renderer, material, points;
    
    const vertexShader = `
    attribute float u_id;
    varying vec2 vUv;
    varying vec3 vColor;
    
    void main() {
    vec3 newPosition = position;
    
    //
    // THE CIRCLES HAVE TO BE POSITIONED IN THE SHADER
    //
    
    if (u_id == 0.0) {
        newPosition = vec3(-0.6, 0.0, 1.2);
        vColor = vec3(1.0, 0.0, 0.0);
    }
    if (u_id == 1.0) {
        newPosition = vec3(0.0, 0.0, 1.0);
        vColor = vec3(0.0, 1.0, 0.0);
    }
    if (u_id == 2.0) {
        newPosition = vec3(0.6, 0.0, 1.1);
        vColor = vec3(0.0, 0.0, 1.0);
    }
    vUv = newPosition.xy;
    gl_PointSize = 200.0; // Control the circle size
    gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
    }
    `;
    const fragmentShader = `
    precision mediump float;
    varying vec2 vUv;
    varying vec3 vColor;
    
    void main() {
    vec2 uv = gl_PointCoord - vec2(0.5); // Center the UVs in the circle
    float dist = length(uv);
    float alpha = smoothstep(0.5, 0.1, dist); // Smooth fade at edges
    
    vec3 color = vColor; //vec3(1.0, 0.0, 0.0); // Red color
    
    if (dist > 0.5) discard; // Make the circle smooth
    gl_FragColor = vec4(color, alpha);
    }
    `;
    
    const VERTEX_COUNT = 3;
    
    // Initial vertex positions are [0, 0, 0]
    const getInitialVertices = () => {
    const vertices = new Float32Array(VERTEX_COUNT * 3);
    return vertices;
    };
    
    // Generate vertex IDs
    const getVertexIds = () => {
    const ids = new Float32Array(VERTEX_COUNT);
    for (let i = 0; i < VERTEX_COUNT; i++) {
        ids[i] = i;
    }
    return ids;
    };
    function init() {
    scene = new THREE.Scene();
    
    camera = new THREE.PerspectiveCamera(
        50,
        window.innerWidth / window.innerHeight,
        1,
        10000
    );
    camera.position.z = 4;
    scene.add(camera);
    
    const uniforms = {};
    
    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute("position", new THREE.BufferAttribute(getInitialVertices(), 3));
    geometry.setAttribute("u_id", new THREE.BufferAttribute(getVertexIds(), 1));
    
    material = new THREE.ShaderMaterial({
        uniforms: uniforms,
        vertexShader: vertexShader,
        fragmentShader: fragmentShader,
        transparent: true,
        //
        // THE OPACITY CUT OCCURES WHEN DEPTH TEST IS ON
        //
        depthTest: true,
    });
      
    material.depthWrite = false;
    
    points = new THREE.Points(geometry, material);
    scene.add(points);
    
    renderer = new THREE.WebGLRenderer({ alpha: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);
    }
    function animate() {
    requestAnimationFrame(animate);
    renderer.render(scene, camera);
    }
    
    window.addEventListener("load", () => {
    init();
    animate();
    });
    
    </script>

    Edit

    I messed around and search a little bit... because with these output parameters, using transparency can be tricky. It seems that the approach where z-sorting depends on the camera position and later subsequent adjustment of the fragment is the most sensible (because you wrote that you will not manipulate the camera). The solution is not perfect (because there is no such solution at the moment, as far as I know), but it is probably the most sensible, apart from some sophisticated ones.

    Also take a look at this thread, because that's where I got the code.

    https://discourse.threejs.org/t/need-help-making-transparency-show-correctly-in-point-cloud/51971

    Also maybe this will be interested for you

    https://developer.nvidia.com/gpugems/gpugems2/part-vi-simulation-and-numerical-algorithms/chapter-46-improved-gpu-sorting

    *{margin:0}
    <script type="importmap">
      {
    "imports": {
      "three": "https://unpkg.com/three@0.174.0/build/three.module.js",
      "three/addons/": "https://unpkg.com/three@0.174.0/examples/jsm/"
    }
      }
    </script>
    
    <script type="module">
    import * as THREE from 'three';
    
    let camera, scene, renderer, points;
    
    function init() {
      scene = new THREE.Scene();
      scene.background = new THREE.Color(0xffffff);
      camera = new THREE.PerspectiveCamera(
        50,
        window.innerWidth / window.innerHeight,
        1,
        10000
      );
    
      camera.position.z = 10;
    
      const geometry = new THREE.BufferGeometry();
      const numPoints = 3;
      // ppositions for three points _org particles:
      // Red: (-0.5, 0, 1.2), Blue: (0.5, 0, 1.1), Green: (0, 0, 1.0)
      const positions = new Float32Array([-0.5, 0, 1.2, 0.5, 0, 1.1, 0.0, 0, 1.0]);
      const colors = new Float32Array([1, 0, 0, 0, 0, 1, 0, 1, 0]);
      geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
      geometry.setAttribute("customColor", new THREE.BufferAttribute(colors, 3));
    
      const vertexShader = `
        attribute vec3 customColor;
        varying vec3 vColor;
        void main(){
          vColor = customColor;
          gl_PointSize = 80.0;
          gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
      `;
      const fragmentShader = `
        precision mediump float;
        varying vec2 vUv;
        varying vec3 vColor;
    
        void main() {
        vec2 uv = gl_PointCoord - vec2(0.5); // Center the UVs in the circle
        float dist = length(uv);
        float alpha = smoothstep(0.5, 0.1, dist); // Smooth fade at edges
    
        vec3 color = vColor; //vec3(1.0, 0.0, 0.0); // Red color
    
        if (dist > 0.5) discard; // Make the circle smooth
        gl_FragColor = vec4(color, alpha);
        }
      `;
      const material = new THREE.ShaderMaterial({
        vertexShader: vertexShader,
        fragmentShader: fragmentShader,
        transparent: true,
        depthTest: true,
      });
    
      points = new THREE.Points(geometry, material);
      scene.add(points);
    
      renderer = new THREE.WebGLRenderer({ antialias: true });
      renderer.setSize(window.innerWidth, window.innerHeight);
      document.body.appendChild(renderer.domElement);
    }
    function depthSortGeometry(geometry, camera) {
      const positionAttribute = geometry.attributes.position;
      const colorAttribute = geometry.attributes.customColor;
    
      const depthArray = Array.from({ length: positionAttribute.count }, (_, i) => {
        const pos = new THREE.Vector3().fromBufferAttribute(positionAttribute, i);
        return camera.position.distanceTo(pos);
      });
    
      const indices = depthArray
        .map((depth, i) => i)
        .sort((a, b) => depthArray[b] - depthArray[a]);
    
      const newPositionAttribute = new THREE.BufferAttribute(
        new Float32Array(positionAttribute.count * 3),
        3
      );
      const newColorAttribute = new THREE.BufferAttribute(
        new Float32Array(colorAttribute.count * 3),
        3
      );
      for (let i = 0; i < indices.length; i++) {
        newPositionAttribute.setXYZ(
          i,
          positionAttribute.getX(indices[i]),
          positionAttribute.getY(indices[i]),
          positionAttribute.getZ(indices[i])
        );
        newColorAttribute.setXYZ(
          i,
          colorAttribute.getX(indices[i]),
          colorAttribute.getY(indices[i]),
          colorAttribute.getZ(indices[i])
        );
      }
    
      const sortedGeometry = new THREE.BufferGeometry();
      sortedGeometry.setAttribute("position", newPositionAttribute);
      sortedGeometry.setAttribute("customColor", newColorAttribute);
    
      return sortedGeometry;
    }
    
    function animate() {
      requestAnimationFrame(animate);
    
      const sortedGeometry = depthSortGeometry(points.geometry, camera);
      points.geometry.dispose();
      points.geometry = sortedGeometry;
    
      renderer.render(scene, camera);
    }
    
    window.addEventListener("load", () => {
    init();
    animate();
    });
    window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
    });
    
    </script>