From 71b597df702bcfd2281df1f8115a2c06c406de5d Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Sat, 4 Apr 2026 04:55:20 +0200 Subject: [PATCH 1/5] Fix Adreno GPU crashes from symbol UBO dynamic indexing Disable symbol UBO batching on Adreno GPUs (Qualcomm, used in Pixel 7/8/8A/9a) where GLSL ES 3.00 dynamic indexing into UBO arrays causes GPU faults and page crashes during zoom/pan. Also fix readFloat/readUint in the symbol shader to use the existing Adreno-safe swizzle helpers, correct a stale getMaxFeatureCount default, and guard against zero-division in the cutoff formula. Fixes #13651 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 9 +++++++++ src/data/bucket/symbol_properties_ubo.ts | 2 +- src/gl/context.ts | 8 ++++---- src/render/cutoff.ts | 2 +- src/shaders/symbol.vertex.glsl | 4 ++-- test/unit/data/symbol_property_binder_ubo.test.ts | 6 +++--- 6 files changed, 20 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afedc76f78d..c8470686309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 3.21.1 + +### Bug fixes 🐞 + +- Fix map crashes on Android Pixel devices (Adreno GPUs) during zoom/pan interactions caused by symbol UBO dynamic indexing. +- Fix `readFloat`/`readUint` in the symbol vertex shader to use Adreno-safe explicit swizzle helpers instead of direct bracket indexing. +- Fix stale default in `getMaxFeatureCount` that could cause incorrect symbol batching limits. +- Guard against potential division by zero in the cutoff fade-range calculation on mobile viewports. + ## 3.20.0-rc.1 ### Features and improvements ✨ diff --git a/src/data/bucket/symbol_properties_ubo.ts b/src/data/bucket/symbol_properties_ubo.ts index 0d06df9c24a..e5f5e5acc45 100644 --- a/src/data/bucket/symbol_properties_ubo.ts +++ b/src/data/bucket/symbol_properties_ubo.ts @@ -166,7 +166,7 @@ export class SymbolPropertiesUBO { * Maximum number of features that fit in one UBO batch given a header. * Returns Infinity when dataDrivenBlockSizeVec4 is 0 (all properties constant). */ - static getMaxFeatureCount(header: SymbolPropertyHeader, propsDwords: number = 4096 - SymbolPropertiesUBO.HEADER_DWORDS): number { + static getMaxFeatureCount(header: SymbolPropertyHeader, propsDwords: number = 4096): number { const dataDrivenBlockSizeDwords = header.dataDrivenBlockSizeVec4 * 4; if (dataDrivenBlockSizeDwords === 0) return Infinity; return Math.floor(propsDwords / dataDrivenBlockSizeDwords); diff --git a/src/gl/context.ts b/src/gl/context.ts index 7137222452e..d1844d73b3b 100644 --- a/src/gl/context.ts +++ b/src/gl/context.ts @@ -153,10 +153,10 @@ class Context { // Force manual rendering for instanced draw calls having gl_InstanceID usage in the shader for PowerVR adapters this.forceManualRenderingForInstanceIDShaders = (options && !!options.forceManualRenderingForInstanceIDShaders) || (this.renderer && this.renderer.indexOf("PowerVR") !== -1); - // Disable symbol UBO batching for PowerVR GPUs. PowerVR drivers do not correctly handle - // dynamic (non-uniform) indexing into UBO arrays, which is technically undefined behavior - // in GLSL ES 3.00. The fallback uses pragma-based paint properties. - this.disableSymbolUBO = (options && !!options.forceDisableSymbolUBO) || (this.renderer && this.renderer.indexOf("PowerVR") !== -1); + // Disable symbol UBO batching for PowerVR and Adreno GPUs. These drivers do not + // correctly handle dynamic (non-uniform) indexing into UBO arrays, which is technically + // undefined behavior in GLSL ES 3.00. The fallback uses pragma-based paint properties. + this.disableSymbolUBO = (options && !!options.forceDisableSymbolUBO) || (this.renderer && (this.renderer.indexOf("PowerVR") !== -1 || this.renderer.indexOf("Adreno") !== -1)); if (!this.options.extTextureFloatLinearForceOff) { this.extTextureFloatLinear = gl.getExtension('OES_texture_float_linear'); diff --git a/src/render/cutoff.ts b/src/render/cutoff.ts index 21084b87002..2c52a7b5664 100644 --- a/src/render/cutoff.ts +++ b/src/render/cutoff.ts @@ -76,7 +76,7 @@ export const getCutoffParams = (painter: Painter, cutoffFadeRange: number): Cuto }; } - const zRange = tr._farZ - tr._nearZ; + const zRange = Math.max(tr._farZ - tr._nearZ, 1.0); const fadeRangePixels = cutoffFadeRange * tr.height * FADE_RANGE_HEIGHT_SCALE; // Half-rate exponential zoom scaling: grows with zoom but stays bounded diff --git a/src/shaders/symbol.vertex.glsl b/src/shaders/symbol.vertex.glsl index a62bf022724..d612ef490cc 100644 --- a/src/shaders/symbol.vertex.glsl +++ b/src/shaders/symbol.vertex.glsl @@ -275,11 +275,11 @@ vec4 readVec4(uint baseOffsetVec4, uint propertyOffsetDwords) { } float readFloat(vec4 slot, uint propertyOffsetDwords) { - return slot[propertyOffsetDwords % DWORDS_PER_VEC4]; + return vec4At(slot, propertyOffsetDwords % DWORDS_PER_VEC4); } uint readUint(uvec4 slot, uint offset) { - return slot[offset % DWORDS_PER_VEC4]; + return uvec4At(slot, offset % DWORDS_PER_VEC4); } vec2 readVec2(vec4 slot, uint propertyOffsetDwords) { diff --git a/test/unit/data/symbol_property_binder_ubo.test.ts b/test/unit/data/symbol_property_binder_ubo.test.ts index 0a49ea569f0..9b01830c747 100644 --- a/test/unit/data/symbol_property_binder_ubo.test.ts +++ b/test/unit/data/symbol_property_binder_ubo.test.ts @@ -91,8 +91,8 @@ describe('SymbolPropertiesUBO', () => { test('getMaxFeatureCount returns correct value', () => { // dataDrivenBlockSizeVec4=1 → dataDrivenBlockSizeDwords=4 - // propsDwords = 4096 - 12 (HEADER_DWORDS) = 4084 - // maxFeatures = floor(4084 / 4) = 1021 + // propsDwords = 4096 + // maxFeatures = floor(4096 / 4) = 1024 const header: SymbolPropertyHeader = { dataDrivenMask: 0b00000100, zoomDependentMask: 0, @@ -100,7 +100,7 @@ describe('SymbolPropertiesUBO', () => { dataDrivenBlockSizeVec4: 1, offsets: [0, 0, 0, 0, 0, 0, 0, 0, 0], }; - expect(SymbolPropertiesUBO.getMaxFeatureCount(header)).toEqual(1021); + expect(SymbolPropertiesUBO.getMaxFeatureCount(header)).toEqual(1024); }); test('getMaxFeatureCount returns Infinity when all constant', () => { From 2bb570a862037fa108243eb02cfdf4165805c6ee Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Sat, 4 Apr 2026 05:08:41 +0200 Subject: [PATCH 2/5] Add regression tests for Adreno UBO disable and cutoff zero-range guard Add unit tests for the two code paths that lacked coverage: - context_gpu_flags.test.ts: verify disableSymbolUBO is false by default and true when forceDisableSymbolUBO option is set. - cutoff.test.ts: verify getCutoffParams returns finite values when zRange is zero or negative, and exercises early-exit paths (zero fade range, terrain active, low pitch). Co-Authored-By: Claude Opus 4.6 (1M context) --- test/unit/gl/context_gpu_flags.test.ts | 22 ++++++ test/unit/render/cutoff.test.ts | 94 ++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 test/unit/gl/context_gpu_flags.test.ts create mode 100644 test/unit/render/cutoff.test.ts diff --git a/test/unit/gl/context_gpu_flags.test.ts b/test/unit/gl/context_gpu_flags.test.ts new file mode 100644 index 00000000000..b71da311662 --- /dev/null +++ b/test/unit/gl/context_gpu_flags.test.ts @@ -0,0 +1,22 @@ +import {test, expect, describe} from '../../util/vitest'; +import Context from '../../../src/gl/context'; + +describe('Context GPU flags', () => { + function createContext(options?: ConstructorParameters[1]) { + const el = window.document.createElement('canvas'); + const gl = el.getContext('webgl2'); + return new Context(gl, options); + } + + describe('disableSymbolUBO', () => { + test('disabled by default on standard GPUs', () => { + const context = createContext(); + expect(context.disableSymbolUBO).toBe(false); + }); + + test('enabled when forceDisableSymbolUBO option is set', () => { + const context = createContext({forceDisableSymbolUBO: true}); + expect(context.disableSymbolUBO).toBe(true); + }); + }); +}); diff --git a/test/unit/render/cutoff.test.ts b/test/unit/render/cutoff.test.ts new file mode 100644 index 00000000000..49e137f43a1 --- /dev/null +++ b/test/unit/render/cutoff.test.ts @@ -0,0 +1,94 @@ +import {test, expect, describe} from '../../util/vitest'; +import {getCutoffParams} from '../../../src/render/cutoff'; + +// getCutoffParams reads specific fields from Painter and Transform. +// We provide minimal fixtures with just the fields the function accesses, +// avoiding mocking internal domain objects per the test guidelines. +function createPainterFixture(overrides: Record = {}) { + return { + terrain: null, + minCutoffZoom: 5, + transform: { + pitch: 60, + _zoom: 10, + _nearZ: 10, + _farZ: 5000, + height: 800, + cameraToCenterDistance: 1000, + isLODDisabled: () => false, + }, + ...overrides, + }; +} + +describe('getCutoffParams', () => { + test('returns shouldRenderCutoff false when cutoffFadeRange is zero', () => { + const painter = createPainterFixture(); + const result = getCutoffParams(painter as never, 0); + expect(result.shouldRenderCutoff).toBe(false); + }); + + test('returns shouldRenderCutoff false when terrain is active', () => { + const painter = createPainterFixture({terrain: {}}); + const result = getCutoffParams(painter as never, 0.5); + expect(result.shouldRenderCutoff).toBe(false); + }); + + test('returns shouldRenderCutoff false when pitch is below activation threshold', () => { + const painter = createPainterFixture({ + transform: { + pitch: 5, + _zoom: 10, + _nearZ: 10, + _farZ: 5000, + height: 800, + cameraToCenterDistance: 1000, + isLODDisabled: () => false, + }, + }); + const result = getCutoffParams(painter as never, 0.5); + expect(result.shouldRenderCutoff).toBe(false); + }); + + test('returns finite cutoff params when zRange is near zero', () => { + const painter = createPainterFixture({ + transform: { + pitch: 60, + _zoom: 10, + _nearZ: 100, + _farZ: 100, // farZ == nearZ → zRange would be 0 without guard + height: 800, + cameraToCenterDistance: 1000, + isLODDisabled: () => false, + }, + }); + const result = getCutoffParams(painter as never, 0.5); + const params = result.uniformValues['u_cutoff_params']; + expect(Number.isFinite(params[2])).toBe(true); + expect(Number.isFinite(params[3])).toBe(true); + }); + + test('returns finite cutoff params when farZ is less than nearZ', () => { + const painter = createPainterFixture({ + transform: { + pitch: 60, + _zoom: 10, + _nearZ: 200, + _farZ: 50, // farZ < nearZ → zRange would be negative without guard + height: 800, + cameraToCenterDistance: 1000, + isLODDisabled: () => false, + }, + }); + const result = getCutoffParams(painter as never, 0.5); + const params = result.uniformValues['u_cutoff_params']; + expect(Number.isFinite(params[2])).toBe(true); + expect(Number.isFinite(params[3])).toBe(true); + }); + + test('returns shouldRenderCutoff true at high pitch with valid fade range', () => { + const painter = createPainterFixture(); + const result = getCutoffParams(painter as never, 0.5); + expect(result.shouldRenderCutoff).toBe(true); + }); +}); From 7d97ebd29603131a05a5f65ad07738551a902486 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Sat, 4 Apr 2026 05:18:02 +0200 Subject: [PATCH 3/5] Harden GPU flag and cutoff regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit context_gpu_flags.test.ts: - Add renderer-based detection tests: Adreno → disabled, PowerVR → disabled, Apple M2 → enabled. Uses getExtension/getParameter interception to inject fake WEBGL_debug_renderer_info strings deterministically. - Guard createContext() with explicit null check on getContext('webgl2'). cutoff.test.ts: - Add invariant assertions for active cutoff: all 4 params finite, relativeCutoffFadeDistance <= relativeCutoffDistance, both non-negative. - Keep existing zero/negative zRange and early-exit coverage. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/unit/gl/context_gpu_flags.test.ts | 50 ++++++++++++++++++++++++++ test/unit/render/cutoff.test.ts | 32 +++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/test/unit/gl/context_gpu_flags.test.ts b/test/unit/gl/context_gpu_flags.test.ts index b71da311662..ef7a7e6e745 100644 --- a/test/unit/gl/context_gpu_flags.test.ts +++ b/test/unit/gl/context_gpu_flags.test.ts @@ -5,9 +5,41 @@ describe('Context GPU flags', () => { function createContext(options?: ConstructorParameters[1]) { const el = window.document.createElement('canvas'); const gl = el.getContext('webgl2'); + if (!gl) throw new Error('WebGL2 context unavailable — cannot run GPU flag tests'); return new Context(gl, options); } + /** + * Returns a WebGL2 context whose WEBGL_debug_renderer_info extension + * reports the given renderer string. This lets us exercise the + * renderer-substring detection in the Context constructor without + * relying on the host GPU. + */ + function createGLWithRenderer(renderer: string): WebGL2RenderingContext { + const el = window.document.createElement('canvas'); + const gl = el.getContext('webgl2'); + if (!gl) throw new Error('WebGL2 context unavailable — cannot run GPU flag tests'); + + const UNMASKED_RENDERER = 0x9246; // WEBGL_debug_renderer_info constant + const fakeExt = {UNMASKED_RENDERER_WEBGL: UNMASKED_RENDERER, UNMASKED_VENDOR_WEBGL: 0x9245}; + + const origGetExtension = gl.getExtension.bind(gl); + const origGetParameter = gl.getParameter.bind(gl); + + gl.getExtension = ((name: string) => { + if (name === 'WEBGL_debug_renderer_info') return fakeExt; + return origGetExtension(name); + }) as typeof gl.getExtension; + + gl.getParameter = ((pname: number) => { + if (pname === UNMASKED_RENDERER) return renderer; + if (pname === fakeExt.UNMASKED_VENDOR_WEBGL) return 'Test Vendor'; + return origGetParameter(pname); + }) as typeof gl.getParameter; + + return gl; + } + describe('disableSymbolUBO', () => { test('disabled by default on standard GPUs', () => { const context = createContext(); @@ -18,5 +50,23 @@ describe('Context GPU flags', () => { const context = createContext({forceDisableSymbolUBO: true}); expect(context.disableSymbolUBO).toBe(true); }); + + test('enabled when renderer contains Adreno', () => { + const gl = createGLWithRenderer('Adreno (TM) 730'); + const context = new Context(gl); + expect(context.disableSymbolUBO).toBe(true); + }); + + test('enabled when renderer contains PowerVR', () => { + const gl = createGLWithRenderer('PowerVR Rogue GE8320'); + const context = new Context(gl); + expect(context.disableSymbolUBO).toBe(true); + }); + + test('disabled for non-matching renderer', () => { + const gl = createGLWithRenderer('ANGLE (Apple, Apple M2 Max, OpenGL 4.1)'); + const context = new Context(gl); + expect(context.disableSymbolUBO).toBe(false); + }); }); }); diff --git a/test/unit/render/cutoff.test.ts b/test/unit/render/cutoff.test.ts index 49e137f43a1..038b4b1eece 100644 --- a/test/unit/render/cutoff.test.ts +++ b/test/unit/render/cutoff.test.ts @@ -22,6 +22,8 @@ function createPainterFixture(overrides: Record = {}) { } describe('getCutoffParams', () => { + // -- early-exit paths -- + test('returns shouldRenderCutoff false when cutoffFadeRange is zero', () => { const painter = createPainterFixture(); const result = getCutoffParams(painter as never, 0); @@ -50,6 +52,8 @@ describe('getCutoffParams', () => { expect(result.shouldRenderCutoff).toBe(false); }); + // -- zero/negative zRange guard -- + test('returns finite cutoff params when zRange is near zero', () => { const painter = createPainterFixture({ transform: { @@ -86,9 +90,37 @@ describe('getCutoffParams', () => { expect(Number.isFinite(params[3])).toBe(true); }); + // -- active cutoff invariants -- + test('returns shouldRenderCutoff true at high pitch with valid fade range', () => { const painter = createPainterFixture(); const result = getCutoffParams(painter as never, 0.5); expect(result.shouldRenderCutoff).toBe(true); }); + + test('all u_cutoff_params components are finite when cutoff is active', () => { + const painter = createPainterFixture(); + const result = getCutoffParams(painter as never, 0.5); + const params = result.uniformValues['u_cutoff_params']; + for (let i = 0; i < 4; i++) { + expect(Number.isFinite(params[i])).toBe(true); + } + }); + + test('relativeCutoffFadeDistance <= relativeCutoffDistance', () => { + const painter = createPainterFixture(); + const result = getCutoffParams(painter as never, 0.5); + const params = result.uniformValues['u_cutoff_params']; + // params[2] = relativeCutoffDistance, params[3] = relativeCutoffFadeDistance + expect(params[3]).toBeLessThanOrEqual(params[2]); + }); + + test('normalized depth values are non-negative when cutoff is active', () => { + const painter = createPainterFixture(); + const result = getCutoffParams(painter as never, 0.5); + const params = result.uniformValues['u_cutoff_params']; + // params[2] = relativeCutoffDistance, params[3] = relativeCutoffFadeDistance + expect(params[2]).toBeGreaterThanOrEqual(0); + expect(params[3]).toBeGreaterThanOrEqual(0); + }); }); From 10e7bfd42fc3a0a5f35c1bc52f716db8989c041c Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Sat, 4 Apr 2026 05:25:47 +0200 Subject: [PATCH 4/5] Make GPU flag tests fully deterministic, add symbol UBO render test - Remove host-dependent "disabled by default on standard GPUs" test that would fail on Adreno/PowerVR machines. All four remaining tests use createGLWithRenderer() to inject a controlled renderer string. - Wire forceDisableSymbolUBO through the render test infrastructure (one-line addition in utils.ts, matching the existing forceManualRenderingForInstanceIDShaders pattern). - Add text-color/property-function-no-symbol-ubo render test: exercises data-driven symbol rendering with UBO disabled, verifying the pragma fallback path renders correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../expected.png | Bin 0 -> 1669 bytes .../style.json | 75 ++++++++++++++++++ test/integration/render-tests/utils.ts | 1 + test/unit/gl/context_gpu_flags.test.ts | 15 +--- 4 files changed, 78 insertions(+), 13 deletions(-) create mode 100644 test/integration/render-tests/text-color/property-function-no-symbol-ubo/expected.png create mode 100644 test/integration/render-tests/text-color/property-function-no-symbol-ubo/style.json diff --git a/test/integration/render-tests/text-color/property-function-no-symbol-ubo/expected.png b/test/integration/render-tests/text-color/property-function-no-symbol-ubo/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..eefc3c69c4adc4b444f001b0763ca6a892552246 GIT binary patch literal 1669 zcmV;027394P)B0A~E(@5;ai*8ticWzUR8)W7%cjyL%&G(9EcnW{XyM^$kiZ^bQKr5269U|7$AK{YYv7z;81uO z7QpGCZcpS5(3G;C3>S=(4M48mq+Cj5z0g~~HFCXwE3!?=9|h~6$LlgAr8-j%2CxO` zmx#0|BEEqo@F&cVicWn}7c~4J{yYOuz+*5A-SNn#l$}auCOioqLp~eNF@q^jfz3gK zK8oc0Y#_2-srulY3{ZX9mde>qXm0_B#5Ck2SPAfsY(F~56OSt?uMLyoI(Q2-?C;2V zkO78`hmenh-iN3?6=;AMNi&@*u0_)Mq%kfh&k7PpBiDuA@Hx=IbSrXHM7k8-i3mO7 zkBHC^6C;w>_$liA6=Z;naUVO-)>UCwpaYqcnG>e-fa7~E`d0yO2#3a58H8QEc}C<8c#9lHzZ$;IH4 zKLGg;qa%E(=_};eh|o(eKyCmRVT3-&4oL+Wpt1_QLJ!t}!+@8W4h*XX^FyfmdtBGT8<{7bA3;oIOosF3L+WL>EP46q{YNq9HKX)^PBVRj+_=koKwEY$4bYZ_x_W)- z0bTtGx7EMW1KP4sSFbNUpsPROw)&Tg2k?LCb}+CN`nA=N&Z@850CakL8t?WNk(v;9 zgA9)u-LWi7MSnQD#>Bn01h>g*yuz}l)olPS`6)aI8sT2#_uxjSksEb)Q+B%xQG24} zwsitmV2g~uIVCSz*QMC#Uj*ZU2u`@&^#vC+IC`Ht7zgg-1)Xp^XE)@TFb4JqgC?B9 zc4$OT%0pl@SU()u7q~>YjpkNZAIc+P7#L`&oLlRk9<`phob%YcY_4Hzt2uAy4WnzI zr_i1P#{s*$F_n(=1`VOdXe>8Rt-qdQh$6)?NT0qNrMr{A5YB^bpstj+0a|Meo0xG1 z2e|X=Hux~|w}BlsZn!gfb;b`ykT-syemZ#~M8RfjXyDnDhr>wR;$382sYnAHK}+qx z!^j`uXRu=qmwm|_=wvfA`jg)R=F`Dh@B&!p?l6uL3n;$_hSQ?nUm*sd)s9sIphkGu z1`j7qBEX?5>(=h^yNyo7H~c8#b~-kb^F~20@++L$dtf03SgKJ5Kz-_VUYrBQP=7y9 zbTJSIs7!W#bJQ2TbHF5wKozA9NQD@H*0YZvhPUWT%%wcJ3YX7H_t4J)=L+WuSCqr4 zFih^bVx$>WKB*7`G&R*1BKj8R(hHZme#imfayAI*ayJ60jsR%WSy7qzoI4DSX~-Qp zW-xf1D@*f%wPZTN05-Cvn4JbNF6gH)212Xy(YOO?7#&f2%GCfgl#o}9r#Vh^x-Z$k6Lm|#Ph$K6WE|QV;I}}&6FfrQ zo0MJuod`9Kbwkl#hCYy9+mEP>E>A^wBlrywqV|-l0c-{hoeg&A2g}t^HxH)61n}v4 zyx$7g$v4nV1K%ufM*e4=r@Rnmz(i2CXuyY3Xp>40DAe2y zc(c+28t|bM+N9D03N?2F-t2$x0rlGSF8}}l|No-riEsb_00v1!K~w_(1Cno$NGeP+ P00000NkvXXu0mjf(su== literal 0 HcmV?d00001 diff --git a/test/integration/render-tests/text-color/property-function-no-symbol-ubo/style.json b/test/integration/render-tests/text-color/property-function-no-symbol-ubo/style.json new file mode 100644 index 00000000000..17d23affa1f --- /dev/null +++ b/test/integration/render-tests/text-color/property-function-no-symbol-ubo/style.json @@ -0,0 +1,75 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 64, + "width": 64, + "forceDisableSymbolUBO": true + } + }, + "center": [ 0, 0 ], + "zoom": 0, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { "x": 0 }, + "geometry": { + "type": "Point", + "coordinates": [ + 0, + -10 + ] + } + }, + { + "type": "Feature", + "properties": { "x": 1 }, + "geometry": { + "type": "Point", + "coordinates": [ + 0, + 10 + ] + } + } + ] + } + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "text", + "type": "symbol", + "source": "geojson", + "layout": { + "text-allow-overlap": true, + "text-field": "Test", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ] + }, + "paint": { + "text-color": { + "property": "x", + "stops": [ + [ + 0, + "red" + ], + [ + 1, + "blue" + ] + ] + } + } + } + ] +} diff --git a/test/integration/render-tests/utils.ts b/test/integration/render-tests/utils.ts index c0a9d6c9cca..e32b9120717 100644 --- a/test/integration/render-tests/utils.ts +++ b/test/integration/render-tests/utils.ts @@ -162,6 +162,7 @@ export async function renderMap(style, options, currentTestName) { extTextureFloatLinearForceOff: options.textureFloatLinear === undefined ? false : !options.textureFloatLinear, // ordinary instancing is enabled by default, manual is disabled forceManualRenderingForInstanceIDShaders: options.forceManualRenderingForInstanceIDShaders, + forceDisableSymbolUBO: options.forceDisableSymbolUBO, }, worldview: options.worldview, maxZoom: options.maxZoom, diff --git a/test/unit/gl/context_gpu_flags.test.ts b/test/unit/gl/context_gpu_flags.test.ts index ef7a7e6e745..55bffcd5753 100644 --- a/test/unit/gl/context_gpu_flags.test.ts +++ b/test/unit/gl/context_gpu_flags.test.ts @@ -2,13 +2,6 @@ import {test, expect, describe} from '../../util/vitest'; import Context from '../../../src/gl/context'; describe('Context GPU flags', () => { - function createContext(options?: ConstructorParameters[1]) { - const el = window.document.createElement('canvas'); - const gl = el.getContext('webgl2'); - if (!gl) throw new Error('WebGL2 context unavailable — cannot run GPU flag tests'); - return new Context(gl, options); - } - /** * Returns a WebGL2 context whose WEBGL_debug_renderer_info extension * reports the given renderer string. This lets us exercise the @@ -41,13 +34,9 @@ describe('Context GPU flags', () => { } describe('disableSymbolUBO', () => { - test('disabled by default on standard GPUs', () => { - const context = createContext(); - expect(context.disableSymbolUBO).toBe(false); - }); - test('enabled when forceDisableSymbolUBO option is set', () => { - const context = createContext({forceDisableSymbolUBO: true}); + const gl = createGLWithRenderer('Generic Desktop GPU'); + const context = new Context(gl, {forceDisableSymbolUBO: true}); expect(context.disableSymbolUBO).toBe(true); }); From efdf3d9a32e36a98e97c29ff0e3bb306ed89150a Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Sat, 4 Apr 2026 05:32:54 +0200 Subject: [PATCH 5/5] Add extension-unavailable unit tests and halo-color render fixture - context_gpu_flags.test.ts: add createGLWithoutRendererInfo() helper that makes WEBGL_debug_renderer_info return null. Two new tests verify disableSymbolUBO is falsy without the extension and that forceDisableSymbolUBO still overrides it. - text-halo-color/property-function-no-symbol-ubo: new render fixture exercising data-driven text-halo-color with UBO disabled. Covers halo_np_color (UBO property index 1), complementing the existing text-color fixture (fill_np_color, index 0). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../expected.png | Bin 0 -> 2682 bytes .../style.json | 72 ++++++++++++++++++ test/unit/gl/context_gpu_flags.test.ts | 32 ++++++++ 3 files changed, 104 insertions(+) create mode 100644 test/integration/render-tests/text-halo-color/property-function-no-symbol-ubo/expected.png create mode 100644 test/integration/render-tests/text-halo-color/property-function-no-symbol-ubo/style.json diff --git a/test/integration/render-tests/text-halo-color/property-function-no-symbol-ubo/expected.png b/test/integration/render-tests/text-halo-color/property-function-no-symbol-ubo/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..28fc63308d074c750eeeb50a61af34b5e3b52985 GIT binary patch literal 2682 zcmV-=3WfEFP)}6>*!M5z(M2W;g?;${vclo}P_mcY(7U7+9-U>8BUmw;`Y1N|+qjSE0u3HG-@yIY_;m~`I?z6E{=&HzWOe@6F7l)nVN(Sxs& z{{%Pz%mzJ+(%qHv^TB7qBJew~5PTfW1v{_5cfDNC!|L5&gI=$td}1>6*ktvi$R7pf zC*22tDdgV=o{|hbZaum(=y}wi1YleiB;DJCM^io&Z$Wr zk}NFAi^x6_z=O!w;ou4c!-*+(J_HUj9$! zB>=UN$$v=-Hy`{*GHYcrpeVhUy1!)%zYUwV#ehS&F&g^^5EsSb0p#8G)?GeGO&b`_)a%+=7Jf$ahM6zxamSa3Lyk#nFZ!=h3pTR*kHfbLWt&%m6N z;;$Zi3{~j6NP=7I3BA$Cw5=m}{4U4+HNbgP1F& z@lI_pKn;%+c!d9t@yY~`X%+dKDJ$w;Tc@?Dn*h`iZ-Xic*hb-~j|$P_eH!&U0GXm* ziK1|iq)W%{PS8DoqC%TysUZWj)PfA(z$mauqEZ_<&OFkdOZ`FM5+D;G8z;^}<`w2t z=}H;pHp1qKN1{3;x1*b9;dh}P6&^2etNSv_jZ#AfM1{oJ?|9gjaaGkzuB>=_lGg)Q zR0gdkZ&F{N{NrA~k`+b4M_WKf$_TfJ43rTcpo6+)1ro5D2dscIOR*GJxxbD_5>z&{ zsh65Ez_Wj1s=+&{!x)9D87&pw3BnQeAnH6Ne+&H$FuBLyLq^LEV=r^aDmB5eWpx7Rqr zqc!Kb&M6id!-7U-_QNZb#9NDB4&a+1pu&3msOP4PLvd@nTpkD-2s$HysTc&D&a2a(YZ;AJu2 zr`qlBiQ;iNP3?-6RnF%^?bMI~#gVuaM==87TMnX7uA@vU8UVZr#%sc*cXH-?VLwe} z_H{WnDXePwLK5y3--JGyOl0#gK__OpEso8uhXzU-3teIVWle|5oLJtT&hyx zZKt|so3A9rRWsiU`?0Bu5|H)3vOFogCKk@s!fRkbNfeYrc{TNzkHtNaCchRK(B$!( zzi*!bn|}>9?+E%5uzC4E{!ic36Z#UcDGqJz*ZUH%wXg7|=GT{iO>t;zzuvk8@FHKp z_FxC#yYMI;S_cMuFoirmg;C8$6}UbCb_O%RPQYNns5W&Atulat;T+9k2BU9WTtMA? zPz@OBdowRM!Lz##Iue(0Z4r0}*qi=(L^|Y;1V%&j0Ndw3T$3)P{w#nyj+zFkH3l%4 z35>(n)7iv=27j4M##^xqbw)N0!R0zYSvQzL#nWl4dx*!>ORX`0#~TjX-{sNeq5ns39`%y|af8aU zyPd8LqXs(9bcp-HZ&}AU@5(fdF|v=4c^#2~LEd|#6HKR=E>@iYw$(#Q8~lmES4ggA zYLfxFZl>$DH54+P0qMKJEu)`5;=ATwy77Y2d=1fe2VGqu71Mqv!=a3`m4S0A?Wse>CgjzgQtp9={|+$$DN zp!7yy6i^N!+F|>IUpa$68E8CB$#@OrX9HbOx`cG4Qq=vXuZ(QH)CvQ5^h3eEuAVVm z+%|DzssJ23*GyJRH-ZdNOHSm9t1Tn-80v0SD(*r(3C~d6T?=S$ut2`Oq9nsYMvEG$ zEe5!yr@Ar@-cZqO24hUzC0a_J@ceW*5Etm+_}FmVozwDN1r07l{V-9shjsw&3r-*|`)pfB6HGl?KQ4eG>@@?%}8hlqA)=CklNGgdV~3W&v%A z-dbF8`&F~>%aqQ@f zz00T`2eATC$r;TjVli^j<6Q%9;Xc%8cMY!+Au?sQ9-1;>0gqim z%*t { return gl; } + /** + * Returns a WebGL2 context where WEBGL_debug_renderer_info is + * unavailable (getExtension returns null), so Context.renderer + * stays undefined. + */ + function createGLWithoutRendererInfo(): WebGL2RenderingContext { + const el = window.document.createElement('canvas'); + const gl = el.getContext('webgl2'); + if (!gl) throw new Error('WebGL2 context unavailable — cannot run GPU flag tests'); + + const origGetExtension = gl.getExtension.bind(gl); + + gl.getExtension = ((name: string) => { + if (name === 'WEBGL_debug_renderer_info') return null; + return origGetExtension(name); + }) as typeof gl.getExtension; + + return gl; + } + describe('disableSymbolUBO', () => { test('enabled when forceDisableSymbolUBO option is set', () => { const gl = createGLWithRenderer('Generic Desktop GPU'); @@ -57,5 +77,17 @@ describe('Context GPU flags', () => { const context = new Context(gl); expect(context.disableSymbolUBO).toBe(false); }); + + test('disabled when WEBGL_debug_renderer_info is unavailable', () => { + const gl = createGLWithoutRendererInfo(); + const context = new Context(gl); + expect(context.disableSymbolUBO).toBeFalsy(); + }); + + test('forceDisableSymbolUBO still works without renderer info', () => { + const gl = createGLWithoutRendererInfo(); + const context = new Context(gl, {forceDisableSymbolUBO: true}); + expect(context.disableSymbolUBO).toBe(true); + }); }); });