From 6e3ad20e3f72cc167f945b5e17512a059f52ebd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Sat, 8 Aug 2026 22:45:04 +0200 Subject: [PATCH] TilemapGPULayer.frag: sample layer data at full precision uLayerSampler and uAnimSampler carry packed 32-bit integer records (tile index in RGB, flip/animation/empty flags in A), not colour, but were declared without a precision qualifier. GLSL ES 1.00 4.5.3 predeclares fragment shader samplers as lowp, which guarantees only 8-bit absolute precision. A driver that implements lowp literally returns the alpha channel slightly below its true value, so texel.a * 255.0 lands just under 16.0 and the bit-exact empty tile test `flags == 16.0` never matches. tile.empty is then false for every cell, getTileTexelCoord() returns vec2(0.0), and each empty cell draws tile 0 of the tileset - opaque in most tilesets - over everything beneath that layer. On a map with several stacked GPU layers sharing a tileset the topmost one wins and the map vanishes under a grid of whatever tile 0 happens to be. Measured on Adreno 740 (Galaxy S23, Chrome 150) with a standalone WebGL page that uploads a 1x1 RGBA8 texel with alpha byte 16, the value generateLayerDataTexture() writes for an empty tile: default (lowp) sampler: flags == 16.0 -> false, decoded 15.996 highp sampler: flags == 16.0 -> true, decoded 16.004 Desktop, Mali and older Adreno promote lowp to fp16/fp32, where 16/255 * 255 rounds back to exactly 16.0, which is why this only reproduces on some devices. Declare both data samplers via a DATA_PRECISION macro defined inside the existing GL_FRAGMENT_PRECISION_HIGH guard, so the shader still compiles where highp is unavailable in fragment shaders. uMainSampler is left at the default precision on purpose: it samples the tileset image as colour, where the default is correct and cheaper on mobile. Also add texelBytes(), snapping a fetched UNORM8 texel back to exact 0-255 values before anything decodes it, and route getLayerData() and floatTexel() through it. The channels there are weighted by powers of 256, so a sub-byte fetch error becomes an error of up to 65536 in the decoded tile index; the reporting device's drift was small enough to spare the index and break only the exact comparison, but nothing bounds it that tightly. Rounding also makes `flags == 16.0` exact rather than merely very likely to be exact. Note that rounding alone is not sufficient for the flag test as written, and neither is a threshold or bit test: the observed value 15.996 fails `== 16.0` and `>= 16.0` alike. Raising the sampler precision fixes it, rounding fixes it, and doing both makes the decode robust regardless of how a driver interprets sampler precision. Regenerates the bundled frag shader. Verified it compiles in a WebGL1 context with MAX_ANIM_FRAMES 0 and 8 and with FEATURE_BORDERFILTER, and that the reporting S23 renders the map correctly. --- .../webgl/shaders/TilemapGPULayer-frag.js | 15 +++++++--- .../webgl/shaders/src/TilemapGPULayer.frag | 29 ++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/renderer/webgl/shaders/TilemapGPULayer-frag.js b/src/renderer/webgl/shaders/TilemapGPULayer-frag.js index 862dfd8564..4c9fbf8638 100644 --- a/src/renderer/webgl/shaders/TilemapGPULayer-frag.js +++ b/src/renderer/webgl/shaders/TilemapGPULayer-frag.js @@ -5,15 +5,17 @@ module.exports = [ '#pragma phaserTemplate(features)', '#ifdef GL_FRAGMENT_PRECISION_HIGH', 'precision highp float;', + '#define DATA_PRECISION highp', '#else', 'precision mediump float;', + '#define DATA_PRECISION mediump', '#endif', '/* Redefine MAX_ANIM_FRAMES to support animations with different frame numbers. */', '#define MAX_ANIM_FRAMES 0', '#pragma phaserTemplate(fragmentDefine)', 'uniform vec2 uResolution;', 'uniform sampler2D uMainSampler;', - 'uniform sampler2D uLayerSampler;', + 'uniform DATA_PRECISION sampler2D uLayerSampler;', 'uniform vec2 uMainResolution;', 'uniform vec2 uLayerResolution;', 'uniform float uTileColumns;', @@ -21,7 +23,7 @@ module.exports = [ 'uniform float uAlpha;', 'uniform float uTime;', '#if MAX_ANIM_FRAMES > 0', - 'uniform sampler2D uAnimSampler;', + 'uniform DATA_PRECISION sampler2D uAnimSampler;', 'uniform vec2 uAnimResolution;', '#endif', 'varying vec2 outTexCoord;', @@ -31,9 +33,14 @@ module.exports = [ '{', ' return uMainResolution;', '}', + 'vec4 texelBytes (vec4 texel)', + '{', + ' return floor(texel * 255.0 + 0.5);', + '}', 'float floatTexel (vec4 texel)', '{', - ' return texel.r * 255.0 + (texel.g * 255.0 * 256.0) + (texel.b * 255.0 * 256.0 * 256.0) + (texel.a * 255.0 * 256.0 * 256.0 * 256.0);', + ' vec4 bytes = texelBytes(texel);', + ' return bytes.r + (bytes.g * 256.0) + (bytes.b * 256.0 * 256.0) + (bytes.a * 256.0 * 256.0 * 256.0);', '}', 'struct Tile', '{', @@ -50,7 +57,7 @@ module.exports = [ ' vec2 tile = floor(texelCoord);', ' vec2 uv = fract(texelCoord);', ' uv.y = 1.0 - uv.y;', - ' vec4 texel = texture2D(uLayerSampler, (tile + 0.5) / uLayerResolution) * 255.0;', + ' vec4 texel = texelBytes(texture2D(uLayerSampler, (tile + 0.5) / uLayerResolution));', ' float flags = texel.a;', ' /* Check for empty tile flag in bit 28. */', ' if (flags == 16.0)', diff --git a/src/renderer/webgl/shaders/src/TilemapGPULayer.frag b/src/renderer/webgl/shaders/src/TilemapGPULayer.frag index 8430963a03..a369a796fd 100644 --- a/src/renderer/webgl/shaders/src/TilemapGPULayer.frag +++ b/src/renderer/webgl/shaders/src/TilemapGPULayer.frag @@ -7,8 +7,10 @@ #ifdef GL_FRAGMENT_PRECISION_HIGH precision highp float; +#define DATA_PRECISION highp #else precision mediump float; +#define DATA_PRECISION mediump #endif /* Redefine MAX_ANIM_FRAMES to support animations with different frame numbers. */ @@ -18,7 +20,16 @@ precision mediump float; uniform vec2 uResolution; uniform sampler2D uMainSampler; -uniform sampler2D uLayerSampler; +// The layer and animation samplers carry packed integer data (tile indices and +// flag bits), not colour, so they need more than the default sampler precision. +// GLSL ES 1.00 declares fragment shader samplers `lowp` by default (spec 4.5.3), +// which only guarantees 8-bit absolute precision. A driver that takes that +// literally decodes the layer texel a fraction below its true byte value, which +// is enough to break the exact comparisons that read the flag bits out of it. +// Observed on Adreno 740/750 (Galaxy S23 / S23 Ultra / Z Fold 7), where every +// empty tile failed its `flags == 16.0` test and drew tile 0 of the tileset over +// the layers beneath it. +uniform DATA_PRECISION sampler2D uLayerSampler; uniform vec2 uMainResolution; uniform vec2 uLayerResolution; uniform float uTileColumns; @@ -27,7 +38,7 @@ uniform float uAlpha; uniform float uTime; #if MAX_ANIM_FRAMES > 0 -uniform sampler2D uAnimSampler; +uniform DATA_PRECISION sampler2D uAnimSampler; uniform vec2 uAnimResolution; #endif @@ -42,10 +53,20 @@ vec2 getTexRes () return uMainResolution; } +// Snap a UNORM8 texel back to the exact 0-255 byte values it was uploaded with. +// The channels below are weighted by powers of 256, so a fetch that is off by a +// fraction of a byte becomes an error of hundreds in the decoded value. +vec4 texelBytes (vec4 texel) +{ + return floor(texel * 255.0 + 0.5); +} + // Convert a vec4 texel to a float. float floatTexel (vec4 texel) { - return texel.r * 255.0 + (texel.g * 255.0 * 256.0) + (texel.b * 255.0 * 256.0 * 256.0) + (texel.a * 255.0 * 256.0 * 256.0 * 256.0); + vec4 bytes = texelBytes(texel); + + return bytes.r + (bytes.g * 256.0) + (bytes.b * 256.0 * 256.0) + (bytes.a * 256.0 * 256.0 * 256.0); } struct Tile @@ -67,7 +88,7 @@ Tile getLayerData (vec2 coord) // Invert Y, as textures are flipped in GL. uv.y = 1.0 - uv.y; - vec4 texel = texture2D(uLayerSampler, (tile + 0.5) / uLayerResolution) * 255.0; + vec4 texel = texelBytes(texture2D(uLayerSampler, (tile + 0.5) / uLayerResolution)); float flags = texel.a;