`DataTexture` results differ from WebGPU and WebGL from 0.171.0 onwards

Hi, everyone!

I was working on a custom TileMap object with a custom shader that successfully used DataTexture objects to pass the tile index and other properties to the TSL shader.

Here’s an example in Three.js 0.170.0:


I recently updated Three.js to the new version 0.173.0 and it broke:


There was something weird but I finally found out what was wrong in my code…
I thought something had been updated and I just had to be compliant with something new or different…

«That’s fine… Let’s fix this!»

Here’s the fix where I “simply” removed a .sub(1) subtraction in my code (there are 3 of them and they’re marked with the comment // <= HERE!)…


So far, when I accidentally browsed my project using my local IP instead of the usual localhost.
WebGPU only works in secure environment (if different from localhost or 127.0.0.1) so it fell back to WebGL which up to 0.170.0 produced the same result as WebGPU.

I spent some time investigating and found out that the wrong “behaviour” begin with 0.171.0.
If I fix WebGPU removing the .sub(1) subtraction, it breaks in WebGL.
If I leave the .sub(1) subtraction to support WebGL, it breaks in WebGPU.

I also tried to compare the differences between the two versions to better understand what has changed… Unfortunately, no luck…

Again… It worked fine in both environment up to 0.170.0.


Am I doing something wrong?

I opened a related issue:

Maybe try the following code in your pens and see if it works. It is your original with slightly modified HTML section and imports in JS.

They both appeared the same to me on Windows desktop computer, with WebGL running in Firefox and WebGPU running in Opera browser.

HTML

<script async src="https://cdn.jsdelivr.net/npm/es-module-shims@1.10.0/dist/es-module-shims.min.js"></script>

<script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.173.0/build/three.webgpu.min.js",
      "three/webgpu": "https://cdn.jsdelivr.net/npm/three@0.173.0/build/three.webgpu.min.js",
      "three/tsl": "https://cdn.jsdelivr.net/npm/three@0.173.0/build/three.tsl.min.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.173.0/examples/jsm/",
      "three/examples/jsm/": "https://cdn.jsdelivr.net/npm/three@0.173.0/examples/jsm/"
    }
  }
</script>

JS

import {
    Color,
    DataArrayTexture,
    DataTexture,
    FloatType,
    LinearSRGBColorSpace,
    Mesh,
    MeshBasicNodeMaterial,
    Object3D,
    PerspectiveCamera,
    PlaneGeometry,
    RedFormat,
    Scene,
    SRGBColorSpace,
    TextureLoader,
    WebGPURenderer,
} from "three";

import {
    convertColorSpace,
    div,
    Fn,
    If,
    Loop,
    max,
    min,
    mix,
    sub,
    texture as texture2d,
    uv,
    vec2,
    vec4

} from "three/tsl";

import { OrbitControls } from "three/addons/controls/OrbitControls.js";

const TILE_AXIS = 4;
const TILE_ATLAS = [
    {
        "identifier": "tile-transparent",
        "source": { "x": 5, "y": 5, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": false
    },
    {
        "identifier": "tile-delimiter",
        "source": { "x": 135, "y": 135, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": false
    },
    {
        "identifier": "tile-hover",
        "source": { "x": 5, "y": 135, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": false
    },
    {
        "identifier": "tile-path",
        "source": { "x": 5, "y": 265, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": false
    },
    {
        "identifier": "tile-top-wall",
        "source": { "x": 265, "y": 5, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": true
    },
    {
        "identifier": "tile-bottom-wall",
        "source": { "x": 265, "y": 5, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": false
    },
    {
        "identifier": "tile-left-wall",
        "source": { "x": 135, "y": 5, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": false
    },
    {
        "identifier": "tile-right-wall",
        "source": { "x": 135, "y": 5, "width": 120, "height": 120 },
        "flipH": true,
        "flipV": false
    },
    {
        "identifier": "tile-top_left-corner",
        "source": { "x": 265, "y": 135, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": true
    },
    {
        "identifier": "tile-top_right-corner",
        "source": { "x": 265, "y": 135, "width": 120, "height": 120 },
        "flipH": true,
        "flipV": true
    },
    {
        "identifier": "tile-bottom_left-corner",
        "source": { "x": 265, "y": 135, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": false
    },
    {
        "identifier": "tile-bottom_right-corner",
        "source": { "x": 265, "y": 135, "width": 120, "height": 120 },
        "flipH": true,
        "flipV": false
    },
    {
        "identifier": "tile-vertical-hallway",
        "source": { "x": 135, "y": 265, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": false
    },
    {
        "identifier": "tile-horizontal-hallway",
        "source": { "x": 265, "y": 265, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": false
    },
    {
        "identifier": "tile-top-dead_end",
        "source": { "x": 135, "y": 395, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": true
    },
    {
        "identifier": "tile-bottom-dead_end",
        "source": { "x": 135, "y": 395, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": false
    },
    {
        "identifier": "tile-left-dead_end",
        "source": { "x": 265, "y": 395, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": false
    },
    {
        "identifier": "tile-right-dead_end",
        "source": { "x": 265, "y": 395, "width": 120, "height": 120 },
        "flipH": true,
        "flipV": false
    },
    {
        "identifier": "tile-block",
        "source": { "x": 5, "y": 395, "width": 120, "height": 120 },
        "flipH": false,
        "flipV": false
    }
];

const TILE_INDEXES = [
    14,  8, 13,  9, 16, 13, 13,  4, 17,  8, 13,  4,  4, 17,  8,  9,
    10, 11,  8,  7,  8, 13, 13,  7,  8, 11,  8, 11, 10, 13, 11, 12,
    16,  9, 12, 12, 12, 16, 13,  7, 10,  9, 10, 13, 13,  9, 14, 12,
     8, 11, 12, 15, 10, 13,  9, 10, 13,  5, 17,  8, 17, 12, 10, 11,
    10,  9, 10,  4, 13, 13,  7, 16,  9,  8, 13,  5,  9, 12,  8, 17,
     8,  5, 13, 11,  8,  9,  6, 13, 11, 12, 14,  8, 11, 12,  6,  9,
    10,  9, 16,  9, 12, 10, 11,  8, 13, 11,  6, 11,  8, 11, 12, 12,
    14, 10,  9, 12, 12,  8, 17, 12, 16, 13,  7,  8,  5,  9, 15, 12,
    12, 16,  5, 11, 12, 10,  9, 12,  8, 13, 11, 10,  9, 10, 17, 12,
    10,  4, 13,  9, 10,  9, 12, 12, 10,  4, 13,  4, 11, 14,  8, 11,
    14, 10, 17, 10,  4, 11, 10, 11, 16, 11,  8,  5, 17, 10,  7, 14,
    12,  8,  9, 14, 12,  8,  4, 17,  8, 13,  5, 13, 13, 13,  3, 11,
     6, 11, 12, 12, 10, 11, 12,  8, 11,  8, 13, 13, 13, 13, 11, 14,
    12, 16,  5,  5, 13,  9, 12, 15,  8,  5, 13, 17,  8, 13, 13,  7,
    10,  9, 16,  9,  8, 11, 10,  9, 12,  8,  4,  9, 10, 13,  9, 12,
    16,  5, 13, 11, 10, 13, 13, 11, 10, 11, 15, 10, 13, 13, 11, 15
];

const TILE_URL = "https://files.byloth.dev/codepen/grid.png";

const TILEMAP_OPTIONS = {
    defaultTile: 2,
    layers: 1,
    precision: "lowp",
    size: { width: 7.5, height: 7.5 },
    tiles: { x: 16, y: 16 },
    transparent: false
};

class TileMap extends Object3D
{
    static get _DefaultOptions()
    {
        return {
            defaultTile: 0,
            layers: 1,
            precision: "lowp",
            size: { width: 1, height: 1 },
            tiles: { x: 1, y: 1 },
            transparent: false
        };
    }

    #_mapPath;

    #_options;

    #_geometry;
    #_material;
    #_mesh;

    #_map;
    #_mapSize;

    #_tileIndexes;
    #_tileSources;
    #_tileFlags;

    #_tilesCount;
    get tilesCount()
    {
        return this.#_tilesCount;
    }

    constructor(mapPath, options = undefined)
    {
        super();

        this.#_tilesCount = 0;
        this.#_mapPath = mapPath;
        this.#_options = { ...TileMap._DefaultOptions, ...options };

        const { precision, size, tiles, transparent } = this.#_options;

        this.#_geometry = new PlaneGeometry(size.width, size.height, tiles.x, tiles.y);
        this.#_material = new MeshBasicNodeMaterial({ precision, transparent });
        this.#_mesh = new Mesh(this.#_geometry, this.#_material);
    }

    get #_colorShader()
    {
        const { width: mW, height: mH } = this.#_mapSize;

        const { layers } = this.#_options;
        const { x: tX, y: tY } = this.#_options.tiles;

        const mapSize = vec2(mW, mH).toVar();
        const tiles = vec2(tX, tY).toVar();

        const getLod = () =>
        {
            const sizeUV = uv().mul(mapSize)
                .toVar();

            const dX = sizeUV.dFdx().length();
            const dY = sizeUV.dFdy().length();

            return max(dX, dY).log2();
        };

        const _tiles = tiles.sub(1)
            .toVar();

        const _readIndex = (layer, coords) => texture2d(this.#_tileIndexes, coords.div(_tiles)).depth(layer)
            .toInt();

        const _sources = vec2(this.#_tilesCount, TILE_AXIS).sub(1)
            .toVar();

        const _readSource = (index, property) => texture2d(this.#_tileSources, vec2(index, property).div(_sources))
            .toInt();

        const _readFlag = (index) => texture2d(this.#_tileFlags, vec2(index, 0).div(this.#_tilesCount - 1)).xy;

        const getSource = (index) =>
        {
            const x = _readSource(index, 0);
            const y = _readSource(index, 1);
            const z = _readSource(index, 2);
            const w = _readSource(index, 3);

            return vec4(x, y, z, w);
        };

        const halfTexel = div(0.5, mapSize).toVar();
        const getTile = (layer, coords) =>
        {
            const tileIndex = _readIndex(layer, coords).toVar();
            const source = getSource(tileIndex).toVar();

            const offset = source.xy.div(mapSize)
                .add(halfTexel)
                .toVar();

            const size = source.zw.div(mapSize)
                .sub(halfTexel)
                .toVar();

            const limit = offset.add(size)
                .sub(halfTexel)
                .toVar();

            return {
                min: offset,
                max: limit,

                getUV: (tileUV) =>
                {
                    const _uv = tileUV.fract()
                        .toVar();

                    const flipped = _readFlag(tileIndex).toVar();

                    If(flipped.x, () => { _uv.x.assign(sub(1, _uv.x)); });
                    If(flipped.y, () => { _uv.y.assign(sub(1, _uv.y)); });

                    _uv.mulAssign(size)
                        .addAssign(offset);

                    return _uv;
                }
            };
        };

        return Fn((builder) =>
        {
            const vUV = uv().mul(tiles)
                .clamp(halfTexel, tiles.sub(halfTexel))
                .toVar();

            const coords = vUV.floor()
                .toVar();

            const lod = getLod()
                .toVar();

            const result = vec4(0, 0, 0, 0).toVar();

            Loop(layers, ({ i: layer }) =>
            {
                const tile = getTile(layer, coords);
                const tileUV = tile.getUV(vUV)
                    .toVar();

                const color = texture2d(this.#_map, tileUV, lod);

                result.rgb.assign(mix(result.rgb, color.rgb, color.a));
                result.a.assign(min(result.a.add(color.a), 1));
            });

            return convertColorSpace(result, SRGBColorSpace, LinearSRGBColorSpace);
        });
    }

    async initialize(scene)
    {
        this.#_map = await new TextureLoader().loadAsync(this.#_mapPath);

        const { width, height } = this.#_map.image;

        const { layers } = this.#_options;
        const { x: tX, y: tY } = this.#_options.tiles;

        const tiles = TILE_ATLAS;

        this.#_mapSize = { width, height };
        this.#_tilesCount = tiles.length;

        const indexes = new Float32Array((tX * tY) * layers);
        for (let index = 0; index < indexes.length; index += 1)
        {
            indexes[index] = this.#_options.defaultTile;
        }

        this.#_tileIndexes = new DataArrayTexture(indexes, tX, tY, layers);
        this.#_tileIndexes.format = RedFormat;
        this.#_tileIndexes.type = FloatType;
        this.#_tileIndexes.needsUpdate = true;

        const sources = new Float32Array(tiles.length * TILE_AXIS);
        const flags = new Uint8Array(tiles.length * 4);

        for (let index = 0; index < tiles.length; index += 1)
        {
            const { source, flipH, flipV } = tiles[index];
            const { x, y, width: z, height: w } = source;

            sources[index] = x;
            sources[(tiles.length * 1) + index] = (height - y) - w;
            sources[(tiles.length * 2) + index] = z;
            sources[(tiles.length * 3) + index] = w;

            flags[(index * 4)] = flipH ? 255 : 0;
            flags[(index * 4) + 1] = flipV ? 255 : 0;
        }

        this.#_tileSources = new DataTexture(sources, tiles.length, TILE_AXIS);
        this.#_tileSources.format = RedFormat;
        this.#_tileSources.type = FloatType;
        this.#_tileSources.needsUpdate = true;

        this.#_tileFlags = new DataTexture(flags, tiles.length, 1);
        this.#_tileFlags.needsUpdate = true;

        this.#_material.colorNode = this.#_colorShader();

        this.add(this.#_mesh);
        scene.add(this);
    }

    setTile(layer, positions, tileIndex)
    {
        const { x: tX, y: tY } = this.#_options.tiles;
        const tSize = tX * tY;

        if (!(positions instanceof Array))
        {
            positions = [positions];
        }

        for (let { x, y } of positions)
        {
            y = (tY - 1) - y;

            const index = (tSize * layer) + (y * tX) + x;

            this.#_tileIndexes.image.data[index] = tileIndex;
        }

        this.#_tileIndexes.needsUpdate = true;
    }
}

async function main()
{
    const renderer = new WebGPURenderer({ antialias: true });

    renderer.setClearColor(new Color(0x3f5f7f));
    renderer.setSize(window.innerWidth, window.innerHeight);

    document.body.appendChild(renderer.domElement);

    const scene = new Scene();
    const camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);

    camera.position.z = 5;

    const controls = new OrbitControls(camera, renderer.domElement);

    window.addEventListener("resize", () =>
    {
        renderer.setSize(window.innerWidth, window.innerHeight);

        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
    });

    const tileMap = new TileMap(TILE_URL, TILEMAP_OPTIONS);
    await tileMap.initialize(scene);

    for (let i = 0; i < TILE_INDEXES.length; i += 1)
    {
        tileMap.setTile(0, { x: i % 16, y: Math.floor(i / 16) }, TILE_INDEXES[i]);
    }

    renderer.setAnimationLoop(() =>
    {
        controls.update();
        renderer.render(scene, camera);
    });
}

window.addEventListener("DOMContentLoaded", main);
1 Like

Thanks, @GitHubDragonFly
It wasn’t ralated to what you suggested.

The fact that you didn’t experience the problem, is related to the GPU of your machine.
I’ve described what the issue was and how I solved it in detail in this comment.

Thanks for your time.