Skip to content

Commit 91b192a

Browse files
obiotclaude
andcommitted
fix(video): six GL-core audit findings, each reproduced before fixing
Beat 1 of the resumed subsystem audit (video/GL core, single-finder + inline verification). Per protocol every bug was demonstrated red with a reproduction test BEFORE being touched; one finding was refined during reproduction (fillRect gradients never used the stencil path — scoped the fix to the shape fills that do). 1. getSupportedCompressedTextureFormats: six WEBKIT fallbacks read the never-assigned `this._gl` → TypeError on any device missing one compression family (everything except ANGLE/Metal Macs — which is why it survived: the dev machines expose every family). → `gl.` 2. Nested multi-effect post-effects: RenderTargetPool's two scalars (_activeBase/_previousBase) became a proper base STACK — each nested sprite pass gets its own lazily-allocated capture/ping-pong pair (2/3, 4/5, …) and begin/end pairs unwind like save/restore; the saved effect projection is a per-pass stack too. Pre-fix, a 2-effect child inside a 2-effect container cleared the parent's capture, popped its slot, and crashed endPostEffect every frame. 3. #gradientMask: write/clip/restore phases now use a high-bit stencil marker (collision-free with mask levels) gated on the VISIBLE value of the active mask (0 when inverted, maskLevel otherwise), and restore setMask's exact stencil test — the old code painted gradients inside inverted cutouts and left a NOTEQUAL parity test that inverted/leaked all subsequent masked draws. setMask/clearMask now track _maskInvert. 4. disableScissor() flushes pending quads before disabling the test (mirrors enableScissor/clipRect/restore) — batched sprites could escape the scissor entirely. 5. WebGL clearRect() erases to actual transparent black per its JSDoc ("#000000" parses with alpha 1 → it painted opaque black; Canvas parity). 6. Canvas clipRect() skip-optimizations now reason in DEVICE space (rotation/skew always clips + cache poisoned): the local-space early-outs dropped canvas-sized clips under a translate and skipped nested same-size clips via the stale cache. Reproductions: tests/glcore-audit.spec.js (5) + tests/canvas-cliprect-transform.spec.js (2) — all 7 red on the unfixed code (nested-FBO red with the exact predicted undefined.unbind). renderTargetPool.spec.js updated to the stack internals + 2 new nesting tests. Full suite 4736 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A
1 parent 2d11694 commit 91b192a

7 files changed

Lines changed: 502 additions & 73 deletions

File tree

packages/melonjs/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@
2626
- **`moveTowards()` never reached its target — and returned the target instead of `this`** (all four vector classes) — the arrival test compared the remaining distance against `step * step` (wrong units: snapped way too early for steps above 1, dithered around the target forever for fractional steps), and on "arrival" it returned the **caller's target vector** without moving `this` at all — so the vector never landed, and chaining onto the result silently mutated the target. It now lands exactly on the target and always returns `this`, per its own documentation; negative-step flee semantics are unchanged.
2727
- **Every preloaded video was fetched with forced anonymous CORS** — the video parser unconditionally stamped the `crossorigin` attribute with the loader's `crossOrigin` setting, which defaults to `undefined` — and per the HTML spec an *invalid* value (the string `"undefined"`) maps to `anonymous`, unlike a *missing* attribute (no CORS). Cross-origin videos served without CORS headers failed to load outright when they would have played fine untainted. The attribute is now only set when `crossOrigin` is actually configured (the empty string remains a valid, honored value).
2828
- **Video preloading hung forever on autoplay-restricted browsers (e.g. iOS Safari) unless `autoplay: false` was spelled out** — the parser waited for `canplay`, which those browsers never fire for a video that isn't allowed to play, and only an *explicit* `autoplay: false` opted into the reliable `loadedmetadata` path — so the common manifest shape (no `autoplay` key) stalled the whole preloader and the game never started. Preloading now completes on `loadedmetadata` unless `autoplay: true` explicitly asks to wait for `canplay`.
29+
- **Compressed-texture loading crashed on any device missing one compression family** — six `WEBKIT_`-prefixed extension fallbacks in `getSupportedCompressedTextureFormats()` referenced `this._gl`, a field that is never assigned (the context lives in `this.gl`). The fallback only evaluates when the primary extension is absent, so the whole feature *worked on ANGLE/Metal Macs* (which expose every family) *and threw a TypeError everywhere else* — Windows (no ASTC), iOS (no S3TC), software renderers — killing every `.dds`/`.pvr`/`.pkm`/`.ktx`/`.ktx2` load.
30+
- **Nested multi-effect post-effects corrupted the render-target pool and crashed every frame** — a renderable with 2+ `postEffects` drawn inside a container with 2+ `postEffects` hit a half-guarded nesting path: the inner pass cleared the outer pass's capture target, its `end` popped the outer pass's pool slot, and the outer `endPostEffect` then dereferenced `undefined` (per-frame TypeError; with camera effects active it silently blitted the wrong texture instead). The pool now tracks a proper stack of passes — each nesting level gets its own lazily-allocated capture/ping-pong pair — and the saved effect projection is a per-pass stack too, so nested post-effect passes compose correctly.
31+
- **Gradient shape fills (`fillEllipse`/`fillPolygon`/`fillArc`/`fillRoundRect`) broke inverted and nested masks** — the internal stencil pass gated its write phase on the *hidden* region of an inverted `setMask` (painting the gradient inside the cutout instead of the visible area) and restored a `NOTEQUAL` parity test instead of the mask's actual test — inverting the mask for every subsequent draw, and leaking outside-all-masks pixels for nested masks. The pass now tags the shape's visible pixels with a high stencil bit (collision-free with mask levels), clips the gradient to the tag, and re-installs the exact stencil test `setMask` had established. (`fillRect` gradients were unaffected — they render through a textured quad.)
32+
- **`disableScissor()` didn't flush, so sprites batched inside the scissor escaped it** — GL scissor applies at draw time, not at batch time; every other scissor mutation (`enableScissor`, `clipRect`, `restore`) flushes pending vertices first, but `disableScissor` didn't — so the common `enableScissor → drawImage → disableScissor` pattern clipped nothing: the quads were still queued when the test was turned off.
33+
- **WebGL `clearRect()` painted opaque black instead of erasing** — its JSDoc (and the Canvas renderer) promise transparent black, but the internal `clearColor()` default (`"#000000"`) parses with alpha 1. Visible on `transparent: true` canvases and inside post-effect render targets, where the "erased" region composited as a solid black box.
34+
- **Canvas `clipRect()` skip-optimizations ignored the active transform** — the "full-viewport no-op" and "same-as-last-clip" early-outs compared the rect's raw *local* values, so a canvas-sized clipped container positioned off-origin got no clip at all, and a clipped container nested inside a same-sized clipped container skipped its own clip via the stale cache — both Canvas-only divergences from the transform-aware WebGL scissor path (the #1349 fix). The skip logic now reasons in device space (and always clips under rotation/skew, where an axis-aligned cache can't).
2935
- **`Path2D.arc()`/`arcTo()`/`ellipse()` appended to an open path started a new sub-path instead of connecting** — the native Path2D spec joins an arc to the current point with a straight line (`arcTo`'s own documentation even promises it), but all three called `moveTo()` unconditionally. Under this release's new multi-sub-path fill semantics that stray sub-path is treated as a **hole**, so an arc appended to a filled outline punched a hole in the shape. They now connect per the spec; the canonical circle-hole idiom (explicit `moveTo` before `arc()`) still registers a hole, and a virgin-path `arc()` still starts cleanly.
3036
- **`Rect.right`/`Rect.bottom` returned the width/height when the edge sat exactly at coordinate 0** — the getters computed `this.left + w || w`, so an edge landing precisely on 0 (falsy) fell back to the size. Bounds and collision math around the origin came out wrong for such rectangles.
3137
- **`Rect.toPolygon()` handed out the rectangle's live vertices**`Polygon.setVertices` stores a `Vector2d[]` by reference, so the "new" polygon shared the rect's actual points array: transforming or mutating the polygon silently corrupted the rectangle. The vertices are now cloned.

packages/melonjs/src/video/canvas/canvas_renderer.js

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1255,31 +1255,59 @@ export default class CanvasRenderer extends Renderer {
12551255
* @param {number} height
12561256
*/
12571257
clipRect(x, y, width, height) {
1258+
const context = this.getContext();
1259+
const t = context.getTransform();
1260+
1261+
// The two skip-optimizations below (full-viewport no-op and
1262+
// same-as-last-clip) must reason in DEVICE space — the rect arrives
1263+
// in LOCAL coordinates (e.g. Container clipping translates first,
1264+
// then clips at (0, 0)), so comparing raw values against the canvas
1265+
// box or the cache is wrong under any transform. The WebGL renderer
1266+
// transforms the rect the same way (see the #1349 fix).
1267+
if (t.b !== 0 || t.c !== 0) {
1268+
// rotated/skewed: the axis-aligned skip logic can't reason about
1269+
// the clipped region — always clip, and poison the cache so a
1270+
// following axis-aligned call can't false-match
1271+
context.beginPath();
1272+
context.rect(x, y, width, height);
1273+
context.clip();
1274+
this.currentScissor[0] = Number.NaN;
1275+
return;
1276+
}
1277+
1278+
// axis-aligned device-space box (scale can be negative — normalize)
1279+
let dx = x * t.a + t.e;
1280+
let dy = y * t.d + t.f;
1281+
let dw = width * t.a;
1282+
let dh = height * t.d;
1283+
if (dw < 0) {
1284+
dx += dw;
1285+
dw = -dw;
1286+
}
1287+
if (dh < 0) {
1288+
dy += dh;
1289+
dh = -dh;
1290+
}
1291+
12581292
const canvas = this.getCanvas();
1259-
// if requested box is different from the current canvas size;
1260-
if (
1261-
x !== 0 ||
1262-
y !== 0 ||
1263-
width !== canvas.width ||
1264-
height !== canvas.height
1265-
) {
1293+
// if the requested box is different from the full canvas;
1294+
if (dx !== 0 || dy !== 0 || dw !== canvas.width || dh !== canvas.height) {
12661295
const currentScissor = this.currentScissor;
12671296
// if different from the current scissor box
12681297
if (
1269-
currentScissor[0] !== x ||
1270-
currentScissor[1] !== y ||
1271-
currentScissor[2] !== width ||
1272-
currentScissor[3] !== height
1298+
currentScissor[0] !== dx ||
1299+
currentScissor[1] !== dy ||
1300+
currentScissor[2] !== dw ||
1301+
currentScissor[3] !== dh
12731302
) {
1274-
const context = this.getContext();
12751303
context.beginPath();
12761304
context.rect(x, y, width, height);
12771305
context.clip();
1278-
// save the new currentScissor box
1279-
currentScissor[0] = x;
1280-
currentScissor[1] = y;
1281-
currentScissor[2] = width;
1282-
currentScissor[3] = height;
1306+
// save the new scissor box in device space
1307+
currentScissor[0] = dx;
1308+
currentScissor[1] = dy;
1309+
currentScissor[2] = dw;
1310+
currentScissor[3] = dh;
12831311
}
12841312
}
12851313
}

packages/melonjs/src/video/rendertarget/render_target_pool.js

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
* Renderer-agnostic — the actual RenderTarget creation is delegated to a
88
* factory function provided by the renderer (WebGL, WebGPU, etc.).
99
*
10-
* Camera effects use pool indices 0 and 1 (capture + ping-pong),
11-
* sprite effects use indices 2 and 3.
10+
* Camera effects use pool indices 0 and 1 (capture + ping-pong); sprite
11+
* effects use indices 2 and 3, and each NESTED sprite pass (a multi-effect
12+
* renderable drawn inside another multi-effect renderable) gets its own pair
13+
* above that (4/5, 6/7, …), tracked by a stack of active bases so begin/end
14+
* pairs compose like save/restore.
1215
* Render targets are lazily created and resized to match the required dimensions.
1316
* @ignore
1417
*/
@@ -21,10 +24,23 @@ export default class RenderTargetPool {
2124
this._factory = factory;
2225
/** @type {RenderTarget[]} */
2326
this._pool = [];
24-
/** @type {number} */
25-
this._activeBase = -1;
26-
/** @type {number} */
27-
this._previousBase = -1;
27+
/**
28+
* active pass bases, innermost last — a STACK, so nested begin/end
29+
* pairs unwind correctly (two scalars silently corrupted the pool on
30+
* nested passes: the inner end() popped the outer pass's slot)
31+
* @type {number[]}
32+
*/
33+
this._baseStack = [];
34+
}
35+
36+
/**
37+
* The base index of the innermost active pass, or -1 when none.
38+
* @returns {number}
39+
*/
40+
get activeBase() {
41+
return this._baseStack.length > 0
42+
? this._baseStack[this._baseStack.length - 1]
43+
: -1;
2844
}
2945

3046
/**
@@ -53,16 +69,24 @@ export default class RenderTargetPool {
5369
* @returns {RenderTarget} the capture target (ready to bind)
5470
*/
5571
begin(isCamera, effectCount, width, height) {
56-
const newBase = isCamera ? 0 : 2;
57-
// guard against nested sprite post-effect passes (not yet supported)
58-
if (!isCamera && this._activeBase === newBase) {
59-
return this.get(this._activeBase, width, height);
72+
let newBase;
73+
if (isCamera) {
74+
newBase = 0;
75+
} else {
76+
// each nested sprite pass gets its own capture/ping-pong pair
77+
// (2/3, then 4/5, 6/7, … — lazily allocated)
78+
let depth = 0;
79+
for (const base of this._baseStack) {
80+
if (base >= 2) {
81+
depth++;
82+
}
83+
}
84+
newBase = 2 + depth * 2;
6085
}
61-
this._previousBase = this._activeBase;
62-
this._activeBase = newBase;
63-
const rt = this.get(this._activeBase, width, height);
86+
this._baseStack.push(newBase);
87+
const rt = this.get(newBase, width, height);
6488
if (effectCount > 1) {
65-
this.get(this._activeBase + 1, width, height);
89+
this.get(newBase + 1, width, height);
6690
}
6791
return rt;
6892
}
@@ -72,21 +96,23 @@ export default class RenderTargetPool {
7296
* @returns {RenderTarget|undefined} the capture target, or undefined if no active pass
7397
*/
7498
getCaptureTarget() {
75-
if (this._activeBase < 0) {
99+
const base = this.activeBase;
100+
if (base < 0) {
76101
return undefined;
77102
}
78-
return this._pool[this._activeBase];
103+
return this._pool[base];
79104
}
80105

81106
/**
82107
* Get the ping-pong render target for the current active pass.
83108
* @returns {RenderTarget|undefined} the ping-pong target, or undefined if no active pass
84109
*/
85110
getPingPongTarget() {
86-
if (this._activeBase < 0) {
111+
const base = this.activeBase;
112+
if (base < 0) {
87113
return undefined;
88114
}
89-
return this._pool[this._activeBase + 1];
115+
return this._pool[base + 1];
90116
}
91117

92118
/**
@@ -95,10 +121,10 @@ export default class RenderTargetPool {
95121
* @returns {RenderTarget|null} the parent target, or null if returning to screen
96122
*/
97123
end() {
98-
this._activeBase = this._previousBase;
99-
this._previousBase = -1;
100-
if (this._activeBase >= 0 && this._pool[this._activeBase]) {
101-
return this._pool[this._activeBase];
124+
this._baseStack.pop();
125+
const base = this.activeBase;
126+
if (base >= 0 && this._pool[base]) {
127+
return this._pool[base];
102128
}
103129
return null;
104130
}
@@ -126,7 +152,6 @@ export default class RenderTargetPool {
126152
}
127153
}
128154
this._pool.length = 0;
129-
this._activeBase = -1;
130-
this._previousBase = -1;
155+
this._baseStack.length = 0;
131156
}
132157
}

packages/melonjs/src/video/webgl/webgl_renderer.js

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ export default class WebGLRenderer extends Renderer {
104104
* @type {Matrix3d}
105105
* @ignore
106106
*/
107-
this._savedEffectProjection = new Matrix3d();
107+
// projections saved by beginPostEffect, one per (possibly nested)
108+
// active pass — a stack, matching RenderTargetPool's base stack
109+
this._effectProjectionStack = [];
108110

109111
/**
110112
* sets or returns the thickness of lines for shape drawing
@@ -162,6 +164,10 @@ export default class WebGLRenderer extends Renderer {
162164
// current gradient state (null when using solid color)
163165
this._currentGradient = null;
164166

167+
// whether the innermost active mask (setMask) is inverted — needed by
168+
// #gradientMask to gate/restore the exact stencil test setMask set
169+
this._maskInvert = false;
170+
165171
/**
166172
* The current transformation matrix used for transformations on the overall scene
167173
* (alias to renderState.currentTransform for backward compatibility)
@@ -314,22 +320,22 @@ export default class WebGLRenderer extends Renderer {
314320
supportedCompressedTextureFormats = {
315321
astc:
316322
gl.getExtension("WEBGL_compressed_texture_astc") ||
317-
this._gl.getExtension("WEBKIT_WEBGL_compressed_texture_astc"),
323+
gl.getExtension("WEBKIT_WEBGL_compressed_texture_astc"),
318324
bptc:
319325
gl.getExtension("EXT_texture_compression_bptc") ||
320-
this._gl.getExtension("WEBKIT_EXT_texture_compression_bptc"),
326+
gl.getExtension("WEBKIT_EXT_texture_compression_bptc"),
321327
s3tc:
322328
gl.getExtension("WEBGL_compressed_texture_s3tc") ||
323-
this._gl.getExtension("WEBKIT_WEBGL_compressed_texture_s3tc"),
329+
gl.getExtension("WEBKIT_WEBGL_compressed_texture_s3tc"),
324330
s3tc_srgb:
325331
gl.getExtension("WEBGL_compressed_texture_s3tc_srgb") ||
326-
this._gl.getExtension("WEBKIT_WEBGL_compressed_texture_s3tc_srgb"),
332+
gl.getExtension("WEBKIT_WEBGL_compressed_texture_s3tc_srgb"),
327333
pvrtc:
328334
gl.getExtension("WEBGL_compressed_texture_pvrtc") ||
329-
this._gl.getExtension("WEBKIT_WEBGL_compressed_texture_pvrtc"),
335+
gl.getExtension("WEBKIT_WEBGL_compressed_texture_pvrtc"),
330336
etc1:
331337
gl.getExtension("WEBGL_compressed_texture_etc1") ||
332-
this._gl.getExtension("WEBKIT_WEBGL_compressed_texture_etc1"),
338+
gl.getExtension("WEBKIT_WEBGL_compressed_texture_etc1"),
333339
etc2:
334340
gl.getExtension("WEBGL_compressed_texture_etc") ||
335341
gl.getExtension("WEBKIT_WEBGL_compressed_texture_etc") ||
@@ -819,8 +825,9 @@ export default class WebGLRenderer extends Renderer {
819825
// since FBO construction temporarily changes GL framebuffer bindings
820826
this.flush();
821827
this.save();
822-
// save the current projection (not part of the render state stack)
823-
this._savedEffectProjection.copy(this.projectionMatrix);
828+
// save the current projection (not part of the render state stack) —
829+
// pushed per pass so nested passes each restore their own
830+
this._effectProjectionStack.push(this.projectionMatrix.clone());
824831

825832
const rt = this._renderTargetPool.begin(isCamera, effects.length, w, h);
826833
// FBO creation/resize uses TEXTURE0 — invalidate the batcher's cache for that unit
@@ -931,7 +938,7 @@ export default class WebGLRenderer extends Renderer {
931938

932939
// restore renderer state and projection saved in beginPostEffect
933940
this.restore();
934-
this.projectionMatrix.copy(this._savedEffectProjection);
941+
this.projectionMatrix.copy(this._effectProjectionStack.pop());
935942
this.currentBatcher.setProjection(this.projectionMatrix);
936943
}
937944

@@ -1057,6 +1064,13 @@ export default class WebGLRenderer extends Renderer {
10571064
* Disable the scissor test, allowing rendering to the full viewport.
10581065
*/
10591066
disableScissor() {
1067+
if (this._scissorActive === true) {
1068+
// drain quads batched under the scissor BEFORE turning the test
1069+
// off — GL scissor applies at draw time, not at batch time
1070+
// (mirrors enableScissor / clipRect / restore, which all flush
1071+
// for exactly this reason)
1072+
this.flush();
1073+
}
10601074
this.gl.disable(this.gl.SCISSOR_TEST);
10611075
this._scissorActive = false;
10621076
}
@@ -1126,7 +1140,10 @@ export default class WebGLRenderer extends Renderer {
11261140
clearRect(x, y, width, height) {
11271141
this.save();
11281142
this.clipRect(x, y, width, height);
1129-
this.clearColor();
1143+
// actual transparent black, per the JSDoc above and the Canvas
1144+
// renderer's native clearRect — the bare clearColor() default
1145+
// ("#000000") parses with alpha 1 and would paint OPAQUE black
1146+
this.clearColor("rgba(0,0,0,0)");
11301147
this.restore();
11311148
}
11321149

@@ -2260,18 +2277,28 @@ export default class WebGLRenderer extends Renderer {
22602277
const gl = this.gl;
22612278
const grad = this._currentGradient;
22622279
const hasMask = this.maskLevel > 0;
2263-
const stencilRef = hasMask ? this.maskLevel + 1 : 1;
22642280
this._currentGradient = null;
22652281

22662282
this.flush();
22672283

22682284
gl.enable(gl.STENCIL_TEST);
22692285
gl.colorMask(false, false, false, false);
22702286

2287+
// The stencil value of currently-VISIBLE pixels, as established by
2288+
// setMask: 0 for an inverted mask, maskLevel otherwise. The shape's
2289+
// visible pixels are tagged with a high-bit marker (mask levels only
2290+
// use the low 7 bits, so the marker can never collide with one),
2291+
// the gradient is clipped to the marker, then the marker is cleared
2292+
// and setMask's exact render test is re-installed.
2293+
const visibleRef = this._maskInvert === true ? 0 : this.maskLevel;
2294+
let markRef = 1;
2295+
22712296
if (hasMask) {
2272-
// nest within existing mask level
2273-
gl.stencilFunc(gl.EQUAL, this.maskLevel, 0xff);
2274-
gl.stencilOp(gl.KEEP, gl.KEEP, gl.INCR);
2297+
markRef = 0x80 | visibleRef;
2298+
// tag: only pixels that are visible under the active mask
2299+
// (low bits == visibleRef) get the marker written
2300+
gl.stencilFunc(gl.EQUAL, markRef, 0x7f);
2301+
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
22752302
} else {
22762303
gl.clear(gl.STENCIL_BUFFER_BIT);
22772304
gl.stencilFunc(gl.ALWAYS, 1, 0xff);
@@ -2281,26 +2308,28 @@ export default class WebGLRenderer extends Renderer {
22812308
drawShape();
22822309
this.flush();
22832310

2284-
// use stencil to clip gradient
2311+
// use stencil to clip the gradient to the marked pixels
22852312
gl.colorMask(true, true, true, true);
2286-
gl.stencilFunc(gl.EQUAL, stencilRef, 0xff);
2313+
gl.stencilFunc(gl.EQUAL, markRef, 0xff);
22872314
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
22882315

22892316
this._currentGradient = grad;
22902317
this.fillRect(x, y, w, h);
22912318
this.flush();
22922319

22932320
if (hasMask) {
2294-
// restore the parent mask level by decrementing stencil
2321+
// clear the marker: write the visible value back on the shape's
2322+
// pixels (REPLACE writes the func ref — a no-op on unmarked
2323+
// visible pixels, and it strips the high bit from marked ones)
22952324
this._currentGradient = null;
22962325
gl.colorMask(false, false, false, false);
2297-
gl.stencilFunc(gl.EQUAL, stencilRef, 0xff);
2298-
gl.stencilOp(gl.KEEP, gl.KEEP, gl.DECR);
2326+
gl.stencilFunc(gl.EQUAL, visibleRef, 0x7f);
2327+
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
22992328
drawShape();
23002329
this.flush();
23012330
gl.colorMask(true, true, true, true);
2302-
// restore parent mask stencil test
2303-
gl.stencilFunc(gl.NOTEQUAL, this.maskLevel + 1, 1);
2331+
// re-install the exact render test setMask had established
2332+
gl.stencilFunc(gl.EQUAL, visibleRef, 0xff);
23042333
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
23052334
this._currentGradient = grad;
23062335
} else {
@@ -2524,6 +2553,7 @@ export default class WebGLRenderer extends Renderer {
25242553
gl.stencilFunc(gl.EQUAL, this.maskLevel, 0xff);
25252554
}
25262555
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
2556+
this._maskInvert = invert === true;
25272557
}
25282558

25292559
/**
@@ -2535,6 +2565,7 @@ export default class WebGLRenderer extends Renderer {
25352565
// flush the batcher
25362566
this.flush();
25372567
this.maskLevel = 0;
2568+
this._maskInvert = false;
25382569
this.gl.disable(this.gl.STENCIL_TEST);
25392570
}
25402571
}

0 commit comments

Comments
 (0)