diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e3090035d9..86b37901c7 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "melonjs", - "version": "20.10.1", + "version": "20.11.0", "description": "Build games with melonJS — 24 guides to the 2D, 2.5D and 3D HTML5 game engine, its conventions and idioms, so generated code runs the first time.", "author": { "name": "melonJS", diff --git a/README.md b/README.md index 695c00c003..edb3651227 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ A modern & fully featured HTML5 game engine ------------------------------------------------------------------------------- ![melonJS](https://melonjs.org/img/alex4-github.png) -[melonJS](https://melonjs.org/) is an open-source 2.5D game engine designed for indie developers — perspective and orthogonal cameras, GPU-accelerated tilemap rendering, post-processing effects, custom shaders, 3D mesh support, polygon-accurate physics, modern Tiled workflows, and high performance. Runs on WebGPU, WebGL 2 or Canvas2D with automatic fallback. The whole engine is ~250 KB minzipped of vanilla JS/TS, with no dependencies and no toolchain lock-in. Built with modern class syntax and bundled with [esbuild](https://esbuild.github.io). +[melonJS](https://melonjs.org/) is an open-source 2D & 3D game engine designed for indie developers — perspective and orthogonal cameras, GPU-accelerated tilemap rendering, post-processing effects, custom shaders, 3D mesh support, polygon-accurate physics, modern Tiled workflows, and high performance. Runs on WebGPU, WebGL 2 or Canvas2D with automatic fallback. The whole engine is ~250 KB minzipped of vanilla JS/TS, with no dependencies and no toolchain lock-in. Built with modern class syntax and bundled with [esbuild](https://esbuild.github.io). [melonJS](https://melonjs.org/) is licensed under the [MIT License](LICENSE.md) and actively maintained by the team at AltByte in Singapore. diff --git a/packages/examples/src/examples/waterOverworld/entities.ts b/packages/examples/src/examples/waterOverworld/entities.ts index 54b92f1aa5..eaed3a14ad 100644 --- a/packages/examples/src/examples/waterOverworld/entities.ts +++ b/packages/examples/src/examples/waterOverworld/entities.ts @@ -229,12 +229,9 @@ export class Male extends me.Sprite { super(x, y, settings); addStripAnimations(this, built.animations, "idle"); - // hit box authored in frame-local (top-left) space; the sprite is - // center-anchored, so shift it by half the frame like the original - this.body = new me.Body( - this, - new me.Rect(35 - this.width * 0.5, 21 - this.height * 0.5, 9, 43), - ); + // hit box in frame-local (top-left) space: shapes are placed in the + // frame the sprite draws in, whatever its anchor + this.body = new me.Body(this, new me.Rect(35, 21, 9, 43)); this.body.collisionType = me.collision.types.PLAYER_OBJECT; this.body.setMaxVelocity(2, 15); this.body.setFriction(0.5, 0); diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index ccb2c14a39..fea495d6b0 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,50 +3,55 @@ ## [20.11.0] (melonJS 2) - _unreleased_ ### Added -- Effects: colors take an alpha. Every color option and `setColor` takes `[r, g, b]` or `[r, g, b, a]`, the alpha being the color's opacity (`[r, g, b]` stays opaque), `RadialGradientEffect` honours the alpha of its `Color`, and `DropShadowEffect` gains the `setColor` the others have +- Effects: colors take an alpha. Every color option and `setColor` takes `[r, g, b]` or `[r, g, b, a]`, the alpha being the color's opacity (`[r, g, b]` stays opaque), `RadialGradientEffect` honours the alpha of its `Color`, and `DropShadowEffect` gains the `setColor` the others have. Four values used to break an effect (WebGL rejected them, and on WebGPU the fourth overwrote the next setting, such as an outline's width); a color of any other length now throws a `TypeError` saying so - Effects: `SwayEffect` leans a sprite or a layer from side to side, as if in a breeze, its top swaying most and everything from its anchor row down staying put. It is a `WaveEffect`, which now takes an `anchor` for the row its wave fades out at, and it wraps its own time so it never stutters on a phone's medium-precision floats - Rendering: `Renderable#sortOffset` lines a renderable up as if it stood that many pixels lower (or higher) when its container sorts on `"y"` or `"x"`, without moving it: a torch drawn over the wall it hangs on, dust sorted with the door it rises from, or art of different heights sorted by its base - Lighting: `Light2d#softness` (0 to 1) fades the edge of a light's hole in the stage's ambient darkness, from a hard rim (0, the default, as before) to a darkness that thins straight from the center (1), along the same falloff as the glow. Hard and soft lights mix in one scene, overlapping lights multiply, and it works on every renderer - Particles: `tintOverLife` gives the tints a particle takes over its life, evenly spaced and blended from the first at birth to the last at the end, such as a flame turning from white hot to deep red. It sets the color only (the fade still sets the opacity), and the blend is taken in 32 steps, which keeps the Canvas renderer to 32 tinted copies of the texture - Animation: `setCurrentAnimation(name, { onFrame })` calls back with each frame index as playback reaches it, from frame 0 when the animation starts, so a footstep or a hit can land on its frame. It applies to the frame animations of a `Sprite` or a `Sprite3d` - Audio: `playTrack(name, { volume, sprite })` plays one region of a clip as the track and loops on it. A region of a loop's exact length keeps an MP3 loop gapless in Safari, which plays the padding an MP3 encoder adds to the end of the file -- Tiled: a tile object with no class of its own is built by the class registered under its tile's class, as Tiled shows it, so a class set once on a tile in the tileset applies to every object placed from it. The object's own class or registered name still comes first, and a tile classed like a built-in (`Sprite`, `Trigger`, `shape`...) stays a plain tile +- Tiled: a tile object with no class of its own is built by the class registered under its tile's class, as Tiled shows it, so a class set once on a tile in the tileset applies to every object placed from it, and `TMXTileset#getTileClass()` returns that class. The object's own class or registered name still comes first, and a tile classed like a built-in (`Sprite`, `Trigger`, `shape`...) stays a plain tile - Tiled: `TMXObject#resolvedProperty()` and `resolvedProperties()` return an object's custom properties together with those it inherits from its tile, the object's own winning, as the methods of the same names in Tiled's scripting API do. Only the object's own properties are set on it by name, as before -- Tiled: `TMXTileset#getTileClass()` returns the class set on a tile in Tiled's tileset editor +- Tiled: a tileset, a tile of an image collection or an image layer whose image has a transparent color (`trans` in TMX, `transparentcolor` in JSON) is drawn with that color transparent, as Tiled shows it. The color is cleared once when the map loads, on a copy of the image - Typings: `TMXObject`, the settings a class registered with `registerTiledObjectClass` (or a factory registered with `registerTiledObjectFactory`) receives, is exported as a type, and both functions are typed with it. A class or factory typed with its own settings still compiles +- Rendering: `textureFilter: "linear-mipmap"` samples a texture's mipmaps wherever it is drawn smaller than its size, so a zoomed-out camera, a minimap or a scaled-down sprite no longer shimmers as it moves; it covers sprites, text and tile layers on WebGL and WebGPU. A sprite cut from an atlas or a tileset keeps its samples inside its own frame, so its neighbours no longer streak along its edges, and a texture draws as under `"linear"` at its size or larger +- Camera: `Renderable#visibilityLayer` and `Camera2d#cullMask` choose what each camera draws: a camera draws a renderable only if they share a bit, so a minimap can leave out the particles, or a `Light2d` can show in one camera only. By default every renderable is on layer 1 and every camera draws every layer; a container left out is left out with everything in it, and the layer can be set on a Tiled object as an `int` custom property +- Application: the `syncToDisplay` setting keeps motion steady on a display that refreshes at `maxfps` (60 by default) or a whole multiple of it, such as 60 or 120 Hz. Frame times are counted in whole display frames, so a frame no longer runs no update and the next one two, which made scrolling stutter; it is off by default, and `timer.syncToDisplay` switches it at any time ### Changed +- Rendering: `renderer.uvOffset` and the `textureSeamFix` setting are removed. The Canvas renderer drew tiles a pixel larger to hide the seams between them, which no longer appear; code that added `uvOffset` to a size should drop it - Rendering: a container sorted on `"x"` or `"y"` keeps its children in order as they move. With `autoSort` on, the default, it checks the order at the end of every update and re-sorts when anything is out of place, so a walking character no longer needs a `sort()` call to pass in front of and behind things. A frame where nothing moved costs one pass over the children and causes no redraw; turn `autoSort` off to keep the order you set. A `"z"` sort is unchanged ### Fixed -- Effects: a color given four values no longer breaks the effect. WebGL rejected it, and on WebGPU the fourth value overwrote the next setting, such as an outline's width; a color of any other length now throws a `TypeError` saying so - Effects: a colored drop shadow below full opacity is no longer too bright, nor are the soft edges of an outline. Both added their color at full strength whatever its opacity -- TextureAtlas: drawing part of an image, a bitmap font glyph or a repeating pattern no longer adds a region to the atlas for every new source rectangle. Those regions piled up for as long as the atlas lived, and turned up among the frames an animation is built from ([#1281](https://github.com/melonjs/melonJS/issues/1281), thanks @snowyukitty) -- TextureAtlas: on an atlas with pages of different sizes, a source rectangle drawn from one page no longer reuses the UVs worked out for the same rectangle on another (thanks @snowyukitty) +- TextureAtlas: drawing part of an image, a bitmap font glyph or a repeating pattern no longer adds a region to the atlas for every new source rectangle, and on an atlas with pages of different sizes no longer reuses the UVs worked out for the same rectangle on another page. Those regions piled up for as long as the atlas lived, and turned up among the frames an animation is built from ([#1281](https://github.com/melonjs/melonJS/issues/1281), thanks @snowyukitty) - Tiled: objects on isometric maps are placed where Tiled draws them. Positions are measured in tile heights on both axes, which changes maps whose tiles are not 2:1, and tile objects are anchored at their bottom center, so a game that offset them to compensate should drop that offset (thanks @vtxe02023274) -- Tiled: a tile object resized in Tiled is drawn at its own size, its tile stretched to fill it, and collides at that size. Unless its tile came from an image collection, it was drawn and collided at the size of the tile (thanks @vtxe02023274) -- Tiled: a tile object from an image collection is drawn on its own box when it is resized or flipped in Tiled. A resized one was drawn above and to the left of it, and a flipped one a whole tile width to the right -- Tiled: tile objects are placed by their tileset's object alignment, so a tileset that anchors its objects at the top left, the center or any other point places them there. They were always anchored at the bottom left, or the bottom center on isometric maps -- Tiled: a rotated tile object turns about the point its tileset aligns, as in Tiled, which is its bottom left (bottom center on isometric maps) unless the tileset sets another. It turned about another point, so it was drawn and collided away from where Tiled shows it -- Tiled: the collision shapes of rectangle, polygon and polyline objects on isometric maps are projected with the map's tile ratio, as Tiled draws them. They were projected as if the tiles were 2:1, so on a map with wider or narrower tiles they were squeezed or stretched across and no longer lined up with the walls they outline (thanks @vtxe02023274) +- Tiled: a tile object resized or flipped in Tiled is drawn on its own box, its tile stretched to fill it, and collides at that size. One from a tileset image was drawn and collided at the size of its tile, and one from an image collection was drawn above and to the left of its box when resized, and a whole tile width to the right when flipped (thanks @vtxe02023274) +- Tiled: tile objects are placed by their tileset's object alignment, and a rotated one turns about that point, as in Tiled: the bottom left, or the bottom center on isometric maps, unless the tileset sets another. They were always anchored at the bottom left (bottom center on isometric maps), and a rotated one turned about another point, so it was drawn and collided away from where Tiled shows it - Tiled: a map with an animated tile object loads, and the object plays its animation. One from a tileset image threw "string parameters for addAnimation are not allowed for standard spritesheet based Texture", and one from an image collection threw a TypeError - Tiled: in a tile layer, tiles from an image collection whose images are not all the same height each sit on the bottom of their cell, as in Tiled. Every tile was placed as if it were as tall as the tallest image of the collection - Tiled: a collision object whose shape reaches left of or above its position, like every rectangle on an isometric map, is found by collision checks, `queryAABB` and `raycast` wherever its shape is. Parts of it were missed, so a character could walk through walls; such an object's `pos` is now the top left corner of its shape +- Tiled: the collision shapes of objects on isometric maps are projected with the map's tile ratio, as Tiled does. Rectangles, polygons and polylines were projected as if the tiles were 2:1, squeezed or stretched across on a map with wider or narrower tiles so they no longer lined up with the walls they outline, and ellipses were not projected at all; a circle stays an `Ellipse`, and any other ellipse, which comes out tilted, becomes a polygon around it (thanks @vtxe02023274) - Tiled: a tile object on an isometric map collides over its image, as on other maps. Its default body was its image's box projected onto the map grid, a slanted shape hanging off the image - Physics: the builtin `raycast` measures a body from the frame its renderable draws in, as collisions have since 20.7. On a body whose anchor is not its top left corner, such as a `Sprite`'s default centre, a ray hit half the body's size away from where it collides, or missed it -- Camera: a zoomed camera follows its target, stays within its bounds and centres on `focusOn` in world units. Zoomed out, it kept the target off centre and scrolled past its bounds; zoomed in, it stopped short of them -- Camera: `localToWorld` and `worldToLocal` take the camera's zoom and its place on the screen (`screenX`, `screenY`) into account. A pointer on a zoomed camera, or on one drawn in a corner of the screen, hit the world somewhere else than it pointed -- Effects: a glow, outline, blur, drop shadow, chromatic aberration, wave, sway, pixelate or bloom on a sprite cut from an atlas or a tileset keeps to the sprite's own frame. Along the frame's edges it picked up the neighbouring frames, and drew a glow or a line from pixels that are not part of the sprite +- Camera: a zoomed camera follows its target, stays within its bounds and centres on `focusOn` in world units, and `localToWorld` and `worldToLocal` take its zoom and its place on the screen (`screenX`, `screenY`) into account. Zoomed out, it kept the target off centre and scrolled past its bounds, zoomed in it stopped short of them, and a pointer on a zoomed camera, or on one drawn in a corner of the screen, hit the world somewhere else than it pointed +- Effects: a glow, outline, blur, drop shadow, chromatic aberration, wave, squish, pixelate or bloom on a sprite cut from an atlas or a tileset keeps to the sprite's own frame. Along the frame's edges it picked up the neighbouring frames, and drew a glow or a line from pixels that are not part of the sprite +- Tiled: a tile layer drawn with smooth filtering, scaled or off the pixel grid, shows no seams between its tiles on WebGL and WebGPU. Along each tile's edge, filtering blended in the next tile of the tileset, or the spacing around it +- Tiled: a pre-rendered tile layer holds every tile it draws, on hexagonal, staggered and oblique maps and with tiles larger than their cell or offset from it, and shows the tiles `setTile` and `clearTile` change. It was baked at an orthogonal layer's size, which cut off the last column of a hexagonal map, what an oblique map draws left of or above its origin and the parts of large tiles reaching past the edge; `setTile` left it as it was, and `clearTile` erased a rectangle placed as on an orthogonal map, cutting into the tiles around it +- Tiled: an orthogonal tile layer with an opacity below 1, or inside a faded container, is drawn faded on WebGL and WebGPU. Its colors were drawn at full strength over what lies behind it, as bright as an opaque layer or brighter, and the container's fade was ignored +- Rendering: on the Canvas renderer, a game created with `antiAlias: false` still draws unsmoothed after the canvas is resized, with no seams between its tiles. A resize reset the canvas's drawing state, which turned smoothing back on, and also reset the blend mode, the color and the line width, join and dash - Camera: a zoomed camera draws its floating renderables, such as a HUD or a `ColorLayer`, and a `ColorLayer` fills all of its view. It skipped them all, as a minimap does, on every renderer -- Lighting: on the Canvas renderer, a light's hole in the ambient darkness is cut where the light is under a scrolled camera. The mask kept the darkness inside the light and cleared it around it instead -- Lighting: on the Canvas renderer, the ambient darkness of a zoomed camera, or of one drawn at an offset such as a minimap, has its holes where the lights are. They were cut as if the camera were neither zoomed nor offset -- Lighting: the ambient darkness covers the first column and row of a camera at a fractional position, such as one following its target with damping. Unless `subPixel` was set, they were left lit -- Lighting: the ambient darkness covers the whole view of a zoomed-out camera. It covered as much of the world as the camera is wide, leaving the rest of the view lit +- Application: a `maxfps` above 120 no longer stops the game. Updates ran every `60 / maxfps` frames rounded to a whole number, which came out as 0, so none ran +- Effects: changing the texture filter after an effect's texture was replaced with `setTexture`, or after the effect was destroyed, no longer raises a WebGL error. The deleted texture was still listed as bound, and the filter change bound it again +- Camera: the follow deadzone has its edges on whole pixels, so a camera following a target on a whole pixel comes to rest on one. Its size, a sixth of the view by default, was kept as a fraction, so with `subPixel` on a camera following its target right or down stopped between two pixels +- Lighting: on the Canvas renderer, a light's hole in the ambient darkness is cut where the light is, under a scrolled or zoomed camera and one drawn at an offset such as a minimap. Under a scrolled camera the mask kept the darkness inside the light and cleared it around it, and the holes of a zoomed or offset camera were cut as if it were neither +- Lighting: the ambient darkness covers the whole view of a camera. Zoomed out, it covered only as much of the world as the camera is wide, and at a fractional position, such as a camera following its target with damping, it left the first column and row lit unless `subPixel` was set - Audio: a sprite region declared looping (`[start, duration, true]`) loops when played with `audio.play(name, { sprite })`. It played only once unless `loop: true` was passed as well - Text: a label whose text is empty or only spaces no longer blanks the whole frame on WebGPU. Its canvas was sized 0 pixels wide, which WebGPU refuses to upload - Typings: `TextureAtlas#getAnimationSettings()` is typed as the settings it returns, so they pass to a `Sprite` constructor from TypeScript without a cast. It returned `object`, and `Sprite3d` typed the named animations an aseprite atlas carries as an array ### Performance +- Effects: sprites drawn one after the other through the same `ShaderEffect` share one draw call, as plain sprites do, even from different frames of an atlas. A uniform set to a new value draws the sprites queued before it first, so each sprite still draws with the values it was given; a `screen_texture` effect still draws each sprite on its own - Rendering: drawing a sprite, a glyph or a pattern computes its UVs from the image size instead of looking them up, with no string built and nothing allocated or kept. On the platformer, text and Jungle Rabbit examples a lookup took 34 to 58 ns and now takes 6 to 10, in Chrome and WebKit: 1 to 2% of a frame's CPU time on the text example, and too little to measure on the platformer ## [20.10.1] (melonJS 2) - _2026-10-21_ diff --git a/packages/melonjs/package.json b/packages/melonjs/package.json index cfaa95308b..a3cf8ffcfc 100644 --- a/packages/melonjs/package.json +++ b/packages/melonjs/package.json @@ -1,31 +1,33 @@ { "name": "melonjs", - "version": "20.10.1", - "description": "melonJS Game Engine", - "homepage": "http://www.melonjs.org/", + "version": "20.11.0", + "description": "A modern & fully featured HTML5 game engine (2D & 3D) for JavaScript/TypeScript, with WebGL/WebGPU rendering, built-in physics, Tiled map & glTF support and WebAudio.", + "homepage": "https://www.melonjs.org/", "type": "module", "sideEffects": [ "./src/polyfill/**" ], "keywords": [ - "2D", - "HTML5", - "javascript", - "TypeScript", - "es6", - "Canvas", - "WebGL", - "WebGL2", - "WebAudio", + "game-engine", + "html5-game-engine", + "javascript-game-engine", + "gamedev", "game", - "engine", + "2d", + "3d", + "html5", + "javascript", + "typescript", + "webgpu", + "webgl", + "webgl2", + "canvas", + "webaudio", + "physics", "tiled", - "tileset", - "mapeditor", - "browser", - "electron", - "mobile", - "cordova" + "tilemap", + "gltf", + "glb" ], "repository": { "type": "git", diff --git a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md index a3bacf8de7..84e1ecd948 100644 --- a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md +++ b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md @@ -1,6 +1,6 @@ --- name: melonjs-camera-and-drawing -description: "Use this skill for camera control and immediate-mode drawing in melonJS — following a target, viewport bounds, shake and fade, secondary cameras, and drawing shapes, lines and gradients inside a custom draw(). Covers world versus screen coordinate conversion, clipping and masking, baking with CanvasRenderTarget, and reading pixels back. Triggers on: Camera2d, viewport, follow, setBounds, shake, fadeIn, fadeOut, worldToLocal, localToWorld, colorMatrix, renderer.fill, renderer.stroke, Rect, Ellipse, Polygon, Line, Gradient, clipRect, mask, CanvasRenderTarget, NoiseTexture2d, lookAt, setBasis, toImageData, getImageData, toBlob, toDataURL, screenshot, read pixels." +description: "Use this skill for camera control and immediate-mode drawing in melonJS — following a target, viewport bounds, shake and fade, secondary cameras and what each one draws, and drawing shapes, lines and gradients inside a custom draw(). Covers world versus screen coordinate conversion, clipping and masking, baking with CanvasRenderTarget, and reading pixels back. Triggers on: Camera2d, viewport, follow, setBounds, minimap, second camera, split screen, cullMask, visibilityLayer, hide from a camera, shake, fadeIn, fadeOut, worldToLocal, localToWorld, colorMatrix, renderer.fill, renderer.stroke, Rect, Ellipse, Polygon, Line, Gradient, clipRect, mask, CanvasRenderTarget, NoiseTexture2d, lookAt, setBasis, toImageData, getImageData, toBlob, toDataURL, screenshot, read pixels." license: MIT --- @@ -191,6 +191,37 @@ View state is per camera, not per scene: a `Camera3d` carries its own distance fog (`setFog`), so a minimap or split-screen view fogs independently of the main one — and a `Camera2d` never fogs. See `melonjs-3d`. +### What each camera draws: `visibilityLayer` and `cullMask` + +Every camera draws the whole world. To keep something off one camera only, put +it on a layer that camera leaves out. A renderable's `visibilityLayer` is the +layers it is on, as bits (1, 2, 4...); a camera's `cullMask` is the layers it +draws. A camera draws a renderable only if the two share a bit: + +```js +const PARTICLES = 2; +emitter.visibilityLayer = PARTICLES; // the particles go on layer 2 +minimap.cullMask = ~PARTICLES; // the minimap draws every layer but 2 +``` + +- By default every renderable is on layer 1 and every camera draws every layer + (`cullMask` is `-1`), so nothing changes until a game sets one. +- A container left out is left out with everything inside it, however deep: + set it on an emitter and none of its particles are drawn. The world itself + obeys it too, and so does what it contains: a child is drawn only if it and + every container above it share a bit with the camera. +- A renderable on layer `0` is drawn by no camera; a `cullMask` of `0` draws + nothing. +- It decides drawing only: what a camera leaves out still updates, collides and + receives pointer events. +- A `Light2d` a camera leaves out, itself or a container it is in, neither + glows, nor cuts its hole in the darkness, nor lights normal-mapped sprites in + that camera. A `Light3d` lights the meshes of every camera. +- It adds to the floating rule: a floating renderable is drawn by a secondary + camera only with `visibleInAllCameras`, and only if the layers match as well. +- On a Tiled object, set it as an `int` custom property named + `visibilityLayer`. + ## Immediate-mode drawing Inside a custom `draw(renderer)` you can draw shapes directly. Remember the two @@ -349,6 +380,9 @@ const data = await rt.toImageData(0, 0, w, h); | GPU memory grows with every level or restart | a `CanvasRenderTarget` created each time: make one and reuse it | | a repainted `CanvasRenderTarget` still shows the old pixels | call `rt.invalidate(renderer)` after painting | | a 3D camera cannot be given an arbitrary up | `Camera3d#lookAt(target, up)` or `setBasis(right, up, forward)`; see `melonjs-3d` | +| particles or effects clutter a minimap | every camera draws everything: put them on their own `visibilityLayer` and leave it out of the minimap's `cullMask` | +| something is missing from one camera only | its `visibilityLayer`, or a container's above it, shares no bit with that camera's `cullMask` | +| a renderable shows in no camera at all | its `visibilityLayer` is `0`, or the world's own layer is left out | ## Related skills diff --git a/packages/melonjs/skills/melonjs-display-and-scaling/SKILL.md b/packages/melonjs/skills/melonjs-display-and-scaling/SKILL.md index 904a1b1798..6d0c540e95 100644 --- a/packages/melonjs/skills/melonjs-display-and-scaling/SKILL.md +++ b/packages/melonjs/skills/melonjs-display-and-scaling/SKILL.md @@ -1,6 +1,6 @@ --- name: melonjs-display-and-scaling -description: "Use this skill when a melonJS game looks blurry, soft, shimmering or cropped, or when choosing how the canvas fills its page. Covers the design resolution, scale and scaleMethod, scaleTarget, scaleRounding for pixel-perfect integer scaling, antiAlias, textureFilter, subPixel, devicePixelRatio, and which setting does which job. Triggers on: blurry, soft, fuzzy, shimmering, pixel art, pixel perfect, crisp, nearest, linear, scale, scaleMethod, scaleTarget, scaleRounding, integer scaling, antiAlias, textureFilter, subPixel, devicePixelRatio, retina, hidpi, design resolution, canvas size, canvas cropped, letterbox, image-rendering, upscale, resolution." +description: "Use this skill when a melonJS game looks blurry, soft, shimmering or cropped, or when choosing how the canvas fills its page. Covers the design resolution, scale and scaleMethod, scaleTarget, scaleRounding for pixel-perfect integer scaling, antiAlias, textureFilter and linear-mipmap for what is drawn small, subPixel, syncToDisplay for steady motion, devicePixelRatio, and which setting does which job. Triggers on: blurry, soft, fuzzy, shimmering, pixel art, pixel perfect, crisp, nearest, linear, scale, scaleMethod, scaleTarget, scaleRounding, integer scaling, antiAlias, textureFilter, linear-mipmap, mipmaps, minimap shimmer, zoomed out, subPixel, syncToDisplay, stutter, judder, uneven scrolling, devicePixelRatio, retina, hidpi, design resolution, canvas size, canvas cropped, letterbox, image-rendering, upscale, resolution." license: MIT --- @@ -23,7 +23,9 @@ separate decisions. Knowing which setting owns which is most of the work. | how a texture is sampled | `antiAlias`, `textureFilter` | nearest or linear, and the CSS upscale filter | `subPixel` is a fifth, orthogonal to all of them: it decides whether a -renderable may sit on a fractional position WITHIN the frame. +renderable may sit on a fractional position WITHIN the frame. `syncToDisplay` +is about the frames in a row rather than one frame: whether motion advances +by the same amount on each. ## Design resolution @@ -155,6 +157,42 @@ can have smooth model edges and nearest-sampled sprites. The presentation half is not separable: there is no way to get MSAA silhouettes and a hard-edged upscale at the same time. +## Drawn small: `textureFilter: "linear-mipmap"` + +A sprite drawn much smaller than its texture (a zoomed-out camera, a minimap, +a scaled-down sprite) shimmers as it moves, even with linear sampling: each +screen pixel covers many texels and linear sampling blends only four of them. +`"linear-mipmap"` samples the texture's mipmaps, smaller copies averaged in +advance, wherever something is drawn below its size: + +```js +const app = new Application(1280, 720, { textureFilter: "linear-mipmap" }); +``` + +At its size or larger a texture draws as it does under `"linear"`, so a game +whose main view is at its size and whose minimap is zoomed out gets a smooth +minimap and an unchanged view. It covers sprites, text and tile layers, not a +video nor a texture the engine renders into, and a `nearest` texture keeps to +its full size. GPU backends only; on WebGPU each texture it applies to takes +about a third more memory. + +A small mipmap level's texels span many of the art's pixels, so a sample near +the edge of a frame would reach into the next frame of the atlas. Under +`"linear-mipmap"` every sprite and tile keeps its samples half a texel of the +level it reads inside its own frame, so the next frame no longer streaks along +its edges. Two limits: + +- a level's texel is an average of a square of the art, 2, 4, 8 pixels a side + and so on. Where a frame's edge falls inside such a square, its outermost + texel already holds some of the neighbour: drawn very small, a frame of an + atlas packed edge to edge can show a faint line of it. Frames on a grid of + a power of two (a 32 or 64 px sheet) are free of it, and a padding of a few + pixels between frames removes it elsewhere; +- a sprite drawn through a `ShaderEffect` samples the texture in the effect's + own program, which is not kept to the frame. + +A repeating pattern is left to wrap. + ## `subPixel` is about position, not scale `subPixel: false` (the default) truncates a renderable's translation to whole @@ -168,6 +206,58 @@ snaps positions and is then scaled by 2.667 has thrown that work away at the last step, which is the contradiction `scaleRounding: "integer"` exists to resolve. +`subPixel: true` alone does not make motion smooth: with nearest sampling a +sprite between two pixels is still drawn on one of them, so a scene moving +4.5 pixels a frame jumps by 4 and 5 in turn. Rendered art (not pixel art) +moves smoothly with `textureFilter: "linear"` as well, which blends it across +the two, while the upscale stays crisp (`antiAlias: true` would also smooth the +whole canvas as it is scaled up, which is what makes it look blurry). A still +scene is only sharp when the camera rests on a whole pixel, so give it a +target on whole pixels: + +```js +const app = new Application(1280, 720, { + scale: "auto", + subPixel: true, + textureFilter: "linear", + syncToDisplay: true, +}); +// the camera follows a point on whole pixels, kept on the hero: it glides +// between pixels while following, and comes to rest on one +const focus = new Vector2d(); +app.viewport.follow(focus, app.viewport.AXIS.BOTH, 0.12); + +// in the hero's update(dt) +focus.set(Math.round(this.pos.x), Math.round(this.pos.y)); +``` + +## `syncToDisplay` is about steady motion + +The game updates in fixed steps, 60 a second by default, as many as the time +since the last frame holds. Browsers report that time with up to a millisecond +of jitter, so on a 60 Hz display a frame now and then gets no update and the +next one gets two: the world holds still for a frame, then moves twice as far. +Scrolling, and anything else moving steadily, stutters while the frame rate +reads a solid 60. + +```js +const app = new Application(1280, 720, { scale: "auto", syncToDisplay: true }); +``` + +With it on, frame times are counted in whole display frames and what is +rounded off is carried to the next frame: one update every frame at 60 Hz, one +every other frame at 120 Hz, and the game keeps its speed. A display within 2% +of `timer.maxfps` (60 by default), or of 2, 3 or 4 times it, or a half, a third +or a quarter of it, is counted at exactly that rate, so a 59.94 Hz screen runs +the game 0.1% slower than the wall clock; leave it off when the updates must +keep to the wall clock exactly. The display's rate is learned as the game runs, +and again when it changes (the window moved to another screen). At a refresh +that is no multiple of the update rate (144 Hz, 165 Hz) the steps cannot fall +evenly however the time is counted. + +It is off by default. It sets `timer.syncToDisplay` when the application is +initialized, which a game can also switch at any time. + ## Soft text next to crisp sprites If everything is crisp EXCEPT the text, the scale factor is not the cause: a @@ -266,6 +356,12 @@ const app = new Application(1024, 576, { | the canvas is cropped or overflows its box | automatic scaling measured the wrong element; name it with `scaleTarget` | | the canvas grows on every resize | the measured ancestor is auto-sized around the canvas itself | | things shimmer as they move | fractional positions; check `subPixel`, and that the scale factor is whole | +| scrolling stutters while the frame rate reads 60 | a frame now and then runs no update and the next two; set `syncToDisplay: true` | +| moving rendered art jumps by whole pixels with `subPixel: true` | nearest sampling still draws it on one pixel; add `textureFilter: "linear"` | +| the whole game looks blurry with `antiAlias: true` | it also smooths the canvas as it is scaled up; use `textureFilter: "linear"` instead | +| a minimap or a zoomed-out view shimmers as it moves | linear sampling blends 4 texels where a pixel covers many; use `textureFilter: "linear-mipmap"` | +| faint lines along sprites drawn small, from the next frame of the atlas | under `"linear-mipmap"` the built-in sprite shaders keep to the frame, but not a `ShaderEffect`'s own sample, and the smallest levels still mix a frame not on a power-of-two grid with its neighbour: pad the atlas | +| a still scene looks a little soft with linear sampling | the camera rests between two pixels; follow a target rounded to whole pixels | | 3D model edges are jagged | `antiAlias: false` also disables MSAA; turn it on and use `textureFilter` for textures | | textures are smoothed when they should be blocky | `antiAlias: true` makes the default texture filter linear; set `textureFilter: "nearest"` | | `scaleRounding: "integer"` changes nothing | it applies to `scaleMethod: "fit"` only; the others resize the renderer | diff --git a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md index b14497a934..48949122b7 100644 --- a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md +++ b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md @@ -499,6 +499,38 @@ loader.preload([{ name: "ramp", type: "shader", data: myFragmentBody }]); label.addPostEffect(loader.getShader("ramp")); ``` +### Sprites sharing one effect share one draw + +Sprites drawn one after the other through the **same** effect instance are one +draw call, as plain sprites are, even when they come from different frames of +an atlas: a hundred enemies outlined by one shared `OutlineEffect` draw once. +Each still draws with the values it was given: a uniform set to a **new** +value draws the sprites queued before it first (setting the value it already +has changes nothing). What splits the batch: + +- a uniform taking a new value between two sprites: give a few sprites the + same value one after the other, rather than a different one each; +- a different texture (the effect path samples one texture per draw); +- a different effect instance, or a sprite without the effect in between; +- a `noise_uv` effect on sprites of different frames or sizes (its rect is + per sprite); +- a `screen_texture` effect, which draws each sprite on its own, as each one + samples a capture of everything drawn before it. + +So share one instance (the loader's `"shader"` type hands one back, see +above) rather than constructing an effect per sprite: *n* instances are *n* +draws as well as *n* programs. One you construct yourself and share needs +`effect.shared = true`, or the first renderable destroyed destroys it for the +others (the loader's are marked shared already): + +```js +const outline = new OutlineEffect(renderer, { color: [1, 1, 1] }); +outline.shared = true; // you destroy it, when no enemy is left +for (const enemy of enemies) { + enemy.addPostEffect(outline); +} +``` + ### Declare a level's effects with that level's assets An effect the loader knows about can be prepared before anything draws it; an @@ -689,6 +721,7 @@ same question after construction. | ported shader renders upside down | a hand-bound `toFrameTexture()` capture — GL is bottom-up, WebGPU top-down | | shadow/smear offset flips on some draws | vertical UV offset not multiplied by `uUVYDir` | | frame rate collapses with many blended sprites | an *advanced* mode (overlay, darken, …) — each draw is a capture plus a composite | +| many sprites with the same effect still cost a draw each | an effect instance per sprite, or a uniform set to a different value per sprite — share one instance and group sprites by value | | animated shader never moves | `setTime` fed milliseconds, or the shader declares no `uTime` | | squash and stretch only bulges, the height never changes, under Matter | the adapter rewrites `currentTransform` every step; set the scale from the sprite's `update()` | | `SquishEffect` squishes the whole screen instead of the sprite | it is chained with another effect, or on the camera; give the sprite it alone | diff --git a/packages/melonjs/skills/melonjs-lighting/SKILL.md b/packages/melonjs/skills/melonjs-lighting/SKILL.md index 76b8cad591..9bc599384c 100644 --- a/packages/melonjs/skills/melonjs-lighting/SKILL.md +++ b/packages/melonjs/skills/melonjs-lighting/SKILL.md @@ -63,6 +63,11 @@ torch.softness = 0.8; // lit core of 20% of the radius, then fading out count. A scene with no soft light draws exactly as before. - To keep a camera out of the darkness (a minimap showing the level lit), override `drawLighting` on the stage and skip that camera. +- To keep one light out of a camera, put it, or a container it is in, on a + `visibilityLayer` the camera's `cullMask` leaves out: there it neither glows, + nor cuts the darkness, nor lights normal-mapped sprites (see + `melonjs-camera-and-drawing`). This is for a `Light2d`: a `Light3d` lights + the meshes of every camera. ## Multiple lights @@ -199,6 +204,7 @@ See the `melonjs-3d` skill. | the lit area has a hard rim | `softness` is 0 (the default): set it on the light | | the darkness opens no wider than the glow | the hole is the light's size: add a bigger `illuminationOnly` light for the opening | | a minimap or second camera is dark too | every camera draws the stage's darkness: override `drawLighting` and skip that camera | +| a light should show in one camera only | put it on a `visibilityLayer` the other camera's `cullMask` leaves out | | sprite not shaded by a light, only glowed over | no `normalMap` on the sprite, or the Canvas backend is active | | normal-mapped sprite is pure black where unlit | `Stage.ambientLightingColor` left at black | | the 33rd light shades nothing | `MAX_LIGHTS = 32` for the normal-map pipeline | diff --git a/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md b/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md index 970697e2b0..5b05a75f08 100644 --- a/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md +++ b/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md @@ -320,6 +320,7 @@ answer when you want discrete puffs rather than a continuous ribbon. | symptom | cause | |---|---| | trail looks flat under a `Camera3d` | `Trail` is 2D; use `Trail3d` | +| particles clutter a minimap or a second camera | every camera draws them: set the emitter's `visibilityLayer` to a layer that camera's `cullMask` leaves out (see `melonjs-camera-and-drawing`) | | `Trail3d` draws nothing, with a console warning | it needs a `Camera3d`; the app's `cameraClass` is still the 2D default | | `trail.tint` does nothing | `Trail` cannot be tinted at all; `Trail3d` can | | assigning `maxPoints` throws | `Trail3d` sizes its geometry once; pass `length` to the constructor | diff --git a/packages/melonjs/skills/melonjs-performance/SKILL.md b/packages/melonjs/skills/melonjs-performance/SKILL.md index ed276e640f..1e4bb578dc 100644 --- a/packages/melonjs/skills/melonjs-performance/SKILL.md +++ b/packages/melonjs/skills/melonjs-performance/SKILL.md @@ -373,6 +373,7 @@ In rough order for a typical 2D game: | a freeze the first time a level shows, never again | shader variants linking on first draw — warm them behind the loading screen | | a freeze the first time an effect appears mid-play (a shield, a hit flash) | the effect attached in the same frame it is drawn — attach it when the pool is built, so the warm-up has a frame to finish | | the same scene stutters once per entry | a derived texture rebuilt per stage: the texture cache keys on the image object, so each rebuild is a new upload | +| steady movement stutters while the frame rate reads 60 | not a cost: frame-time jitter gives a frame no update and the next two; set `syncToDisplay: true` (see `melonjs-display-and-scaling`) | ## Related skills diff --git a/packages/melonjs/skills/melonjs-renderables/SKILL.md b/packages/melonjs/skills/melonjs-renderables/SKILL.md index 686f4868a8..baf0326455 100644 --- a/packages/melonjs/skills/melonjs-renderables/SKILL.md +++ b/packages/melonjs/skills/melonjs-renderables/SKILL.md @@ -297,6 +297,10 @@ other correct ending. - `floating = true` pins a renderable to the screen rather than the world, which is what you want for HUD elements. It opts the object out of camera transforms and out of ancestor position accumulation. +- `visibilityLayer` decides which cameras draw a renderable: a camera draws it + only if it shares a bit with the camera's `cullMask` (every camera, by + default). See `melonjs-camera-and-drawing` for keeping something off a + minimap. ## Prefer built-in features diff --git a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md index ab4e361d0c..c6781480bb 100644 --- a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md +++ b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md @@ -205,7 +205,9 @@ opposite of `level.load`'s. Draw collision geometry in Tiled on an object layer and melonJS turns it into world shapes automatically — rectangles, polygons, polylines, ellipses and -points all work. +points all work. On an isometric map they are projected with the map's tile +ratio; a circle stays an `Ellipse`, but any other ellipse comes out tilted and +becomes a `Polygon`, so code that checks a shape's type should expect either. The contract is the **layer name**: melonJS lowercases it and checks whether it *contains* `"collision"` (so `collision`, `Collision`, `world collision` all @@ -220,7 +222,9 @@ geometry, which the world auto-registers with the active adapter on `addChild`. For per-object physics, set the object's custom properties in Tiled; they arrive on the settings object passed to your entity's constructor. Properties set on -the object's tile are read with `settings.resolvedProperty(name)`. +the object's tile are read with `settings.resolvedProperty(name)`. One is also +applied to the object the map creates: an `int` property named +`visibilityLayer` sets which cameras draw it (see `melonjs-camera-and-drawing`). ## Map orientations @@ -357,6 +361,9 @@ unanimated layer into the offscreen-bake path instead. | a property set on a tile is undefined on the object's `settings` | tile properties are not copied onto `settings`: read them with `settings.resolvedProperty(name)` | | objects placed from a classed tile come out as plain sprites | that class is not registered, it is a built-in name, or the object has a class or a registered name of its own, which come first | | missing tiles or a blank map | the `.tsx` or tileset image was not preloaded | +| a tileset's transparent color (`trans`) still shows (the console says it could not be applied) | the image cannot be read back: one from another origin needs CORS headers on the server and `loader.setOptions({ crossOrigin: "anonymous" })` (without them WebGL and WebGPU cannot draw it at all), and a compressed texture cannot be changed: export it with an alpha channel instead | +| changing tiles at runtime is slow on a pre-rendered layer | outside an orthogonal map whose tiles fit their cells, a changed tile re-bakes the whole layer before it next draws: keep layers you edit often off `preRender` | +| thin lines between tiles on the Canvas renderer, with smoothing on | filtering blends in the next tile of the tileset or its spacing, which a canvas cannot prevent: set `antiAlias: false`, or `preRender` the layer. WebGL and WebGPU keep each tile to itself | | tilemap slow on some machines | layer fell back off the shader path — check the console warning for the reason | ## Related skills diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts index 8c9dd79493..4293d2e1f7 100644 --- a/packages/melonjs/src/application/application.ts +++ b/packages/melonjs/src/application/application.ts @@ -809,6 +809,10 @@ export default class Application { this.isInitialized = true; + // how the update loop counts frame times, on the timer every + // application shares (so the last one initialized decides) + timer.syncToDisplay = this.settings.syncToDisplay; + // Only publish this instance as the default `game` once it is fully // usable — a constructed-but-not-initialized Application has no // renderer, which is precisely the half-built state this split @@ -866,7 +870,9 @@ export default class Application { updateFrameRate(): void { // reset the frame counter this.frameCounter = 0; - this.frameRate = ~~(0.5 + 60 / timer.maxfps); + // an update every `frameRate` frames: every frame at a maxfps of 60 + // or more (rounded down to 0 above 120, it never updated at all) + this.frameRate = Math.max(1, ~~(0.5 + 60 / timer.maxfps)); // set step size based on the updatesPerSecond this.stepSize = 1000 / this.world.fps; diff --git a/packages/melonjs/src/application/defaultApplicationSettings.ts b/packages/melonjs/src/application/defaultApplicationSettings.ts index 5d8574151f..44cd2beb56 100644 --- a/packages/melonjs/src/application/defaultApplicationSettings.ts +++ b/packages/melonjs/src/application/defaultApplicationSettings.ts @@ -26,6 +26,7 @@ export const defaultApplicationSettings = { highPrecisionShader: true, scaleRounding: "fractional", subPixel: false, + syncToDisplay: false, verbose: false, backgroundColor: "#000000", } satisfies Partial; diff --git a/packages/melonjs/src/application/settings.ts b/packages/melonjs/src/application/settings.ts index 9f41983a70..18b9c08e72 100644 --- a/packages/melonjs/src/application/settings.ts +++ b/packages/melonjs/src/application/settings.ts @@ -330,6 +330,20 @@ export type ApplicationSettings = { * when `false`): unchanged behavior. * - `"nearest"` — crisp/pixelated upscaling, regardless of `antiAlias`. * - `"linear"` — smooth, regardless of `antiAlias`. + * - `"linear-mipmap"` — smooth, and a sprite, a tile or a text drawn + * smaller than its texture (a scaled-down sprite, or anything under a + * zoomed-out camera) takes its colour from the texture's mipmaps, its + * pre-shrunk copies, instead of shimmering as it moves. A sprite cut + * from an atlas or a tileset keeps its samples inside its own frame, + * so the neighbouring frames do not streak along its edges (a frame + * whose edges are not on a power of two can still mix a little of its + * neighbour into its outermost pixel at the smallest sizes, and a + * sprite drawn through a {@link ShaderEffect} is not kept to its + * frame); otherwise, drawn at its size or larger, a texture is the same + * as under `"linear"`. Not for the + * textures the engine renders into, nor a video, which keep to their + * full size. On WebGPU each texture it applies to takes about a third + * more memory. * * This is the **default** for every texture; a {@link Mesh} can still override * it per-mesh via its own `textureFilter` setting (which wins). @@ -348,8 +362,13 @@ export type ApplicationSettings = { * antiAlias: true, // MSAA on * textureFilter: "nearest", // textures stay crisp * }); + * + * // smooth, and smooth again when zoomed out + * new Application(1280, 720, { + * textureFilter: "linear-mipmap", + * }); */ - textureFilter: "auto" | "nearest" | "linear"; + textureFilter: "auto" | "nearest" | "linear" | "linear-mipmap"; /** * How many texture units the WebGL multi-texture batchers may use @@ -508,6 +527,35 @@ export type ApplicationSettings = { */ subPixel: boolean; + /** + * Run the game's updates in a steady pattern on a display that refreshes + * at `maxfps`, or a whole multiple or fraction of it: one update every + * frame at 60 Hz, one every other frame at 120 Hz, for the default + * `maxfps` of 60. + * + * The game updates in fixed steps, as many as the time since the last + * frame holds. That time jitters around the display's refresh by up to a + * millisecond, so on a 60 Hz display a frame can get no update and the + * next one two: anything that scrolls or moves steadily stutters. With + * this on, the time is counted in whole display frames, the rest carried + * to the next frame, so the steps fall evenly and the game keeps its + * speed. A display within 2% of `maxfps`, or of 2, 3 or 4 times it, or a + * half, a third or a quarter of it, is counted at exactly that rate (a + * 59.94 Hz screen runs the game 0.1% slower than the wall clock). The + * display's rate is learned as the game runs, and learned again when it + * changes, such as when the window moves to another screen. + * + * It sets `timer.syncToDisplay` when the application is initialized. + * The timer is shared, so with more than one application the last one + * initialized decides, and a game can turn it on or off later through + * `timer.syncToDisplay`. + * @default false + * @example + * // steady updates on a 60 or 120 Hz display + * const app = new Application(1280, 720, { syncToDisplay: true }); + */ + syncToDisplay: boolean; + /** * whether to enable verbose mode (additional console output for debugging) * @default false diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts index cdc3b7172a..d6902e0a41 100644 --- a/packages/melonjs/src/camera/camera2d.ts +++ b/packages/melonjs/src/camera/camera2d.ts @@ -107,6 +107,28 @@ export default class Camera2d extends Renderable { */ damping: number; + /** + * The layers this camera draws, as bits: a renderable is drawn only if + * its {@link Renderable#visibilityLayer} shares a bit with this mask. All + * bits by default, so a camera draws everything. Leave a layer out to + * keep what is on it off this camera only, such as the particles off a + * minimap: `~layer` is every layer but that one. + *

+ * It decides what this camera draws, and nothing else: what it leaves + * out still updates, collides and receives pointer events. A + * {@link Light2d} it leaves out, itself or a container it is in, + * neither glows, nor cuts its hole in the darkness, nor lights + * normal-mapped sprites in this camera. A mask of `0` draws nothing. + * @default -1 (all bits) + * @see Renderable#visibilityLayer + * @example + * // particles on their own layer, and a minimap that leaves it out + * const PARTICLES = 2; + * emitter.visibilityLayer = PARTICLES; + * minimap.cullMask = ~PARTICLES; + */ + cullMask: number; + /** * the closest point relative to the camera. Widened from `-1000` in * 19.7 to accommodate sprites at large `depth` values now that @@ -246,6 +268,8 @@ export default class Camera2d extends Renderable { this.damping = 1.0; + this.cullMask = -1; + this.near = -1e6; this.far = 1e6; @@ -557,21 +581,30 @@ export default class Camera2d extends Renderable { * change the deadzone settings. * the "deadzone" defines an area within the current camera in which * the followed renderable can move without scrolling the camera. + * Its edges are on whole pixels: the size is rounded to the nearest one. * @see {@link follow} * @param w - deadzone width * @param h - deadzone height + * @example + * // the hero moves 200 x 120 pixels around the middle before the camera follows + * app.viewport.follow(hero, app.viewport.AXIS.BOTH); + * app.viewport.setDeadzone(200, 120); */ setDeadzone(w: number, h: number): void { if (typeof this.deadzone === "undefined") { this.deadzone = new Rect(0, 0, 0, 0); } - // reusing the old code for now... + // whole pixels on every side, so a camera following a target that is + // on a whole pixel comes to rest on one (the default, a sixth of the + // view, is a fraction for most sizes) + const width = Math.round(w); + const height = Math.round(h); this.deadzone.pos.set( - ~~((this.width - w) / 2), - ~~((this.height - h) / 2 - h * 0.25), + ~~((this.width - width) / 2), + ~~((this.height - height) / 2 - height * 0.25), ); - this.deadzone.resize(w, h); + this.deadzone.resize(width, height); this.smoothFollow = false; @@ -1208,7 +1241,7 @@ export default class Camera2d extends Renderable { // no-op. const stage = state.current(); renderer.setLightUniforms( - stage?._activeLights, + stage?._lightsFor(this), stage?.ambientLightingColor, translateX, translateY, @@ -1219,17 +1252,21 @@ export default class Camera2d extends Renderable { // for non-default cameras, temporarily expand the camera rect to the // visible world area so TMXLayer draws all tiles within the zoomed view. // When zoom is 1, worldView matches camera size — no visible effect. - if (isNonDefault) { - const view = this.worldView; - const savedWidth = this.width; - const savedHeight = this.height; - this.width = view.width; - this.height = view.height; - container.draw(r, this); - this.width = savedWidth; - this.height = savedHeight; - } else { - container.draw(r, this); + // the world itself obeys the mask, as every container in it does: on + // no layer this camera draws, nothing of it is drawn + if ((container.visibilityLayer & this.cullMask) !== 0) { + if (isNonDefault) { + const view = this.worldView; + const savedWidth = this.width; + const savedHeight = this.height; + this.width = view.width; + this.height = view.height; + container.draw(r, this); + this.width = savedWidth; + this.height = savedHeight; + } else { + container.draw(r, this); + } } // Ground shadows (#1515) are held back until every opaque mesh in the diff --git a/packages/melonjs/src/level/tiled/TMXLayer.js b/packages/melonjs/src/level/tiled/TMXLayer.js index ce622559b6..6bda29fbfc 100644 --- a/packages/melonjs/src/level/tiled/TMXLayer.js +++ b/packages/melonjs/src/level/tiled/TMXLayer.js @@ -1,4 +1,6 @@ +import { Rect } from "../../geometries/rectangle.ts"; import { vector2dPool } from "../../math/vector2d.ts"; +import { boundsPool } from "../../physics/bounds.ts"; import Renderable from "../../renderable/renderable.js"; import CanvasRenderer from "../../video/canvas/canvas_renderer"; import { createCanvas } from "../../video/canvas_factory.js"; @@ -345,16 +347,10 @@ export default class TMXLayer extends Renderable { // per-layer preRender hint this._resolveRenderMode(); - // if pre-rendering method is in use, create an offline canvas/renderer + // if pre-rendering method is in use, bake the layer into an offscreen + // canvas, drawn as one image from then on if (this.renderMode === "prerender" && !this.canvasRenderer) { - this.canvasRenderer = new CanvasRenderer({ - canvas: createCanvas(this.width, this.height), - width: this.width, - height: this.height, - transparent: true, - }); - // pre render the layer on the canvas - this.getRenderer().drawTileLayer(this.canvasRenderer, this, this); + this._bakePreRender(); } // keep `preRender` boolean in sync with the resolved mode (legacy // callers still read it; `Renderer.drawTileLayer` itself reads @@ -364,6 +360,126 @@ export default class TMXLayer extends Renderable { this.isDirty = true; } + /** + * Bake every tile of the layer into its offscreen canvas. The canvas + * covers what the map renderer gives as the layer's bounds, which on a + * hexagonal, staggered or oblique map reach past `cols * tilewidth` + * (and on an oblique one, left of or above 0), grown on every side by + * how far an oversized or offset tile can reach past its cell. `_preRenderX` / + * `_preRenderY` are where the canvas's top left corner sits. + * @ignore + * @internal + */ + _bakePreRender() { + const renderer = this.getRenderer(); + const bounds = renderer.getBounds(this); + // an oversized tile reaches past its cell (which way depends on the + // orientation: up on an orthogonal map, down on a hexagonal one), and + // a tileset's offset moves all of its tiles + const overX = Math.max(this.maxTileSize.width - this.tilewidth, 0); + const overY = Math.max(this.maxTileSize.height - this.tileheight, 0); + let left = bounds.left - overX; + let top = bounds.top - overY; + let right = bounds.right + overX; + let bottom = bounds.bottom + overY; + boundsPool.release(bounds); + let offset = false; + for (let i = 0; i < this.tilesets.length; i++) { + const { x, y } = this.tilesets.getTilesetByIndex(i).tileoffset; + left += Math.min(x, 0); + right += Math.max(x, 0); + top += Math.min(y, 0); + bottom += Math.max(y, 0); + offset = offset || x !== 0 || y !== 0; + } + const x = Math.floor(left); + const y = Math.floor(top); + const width = Math.max(Math.ceil(right) - x, 1); + const height = Math.max(Math.ceil(bottom) - y, 1); + + const previous = this.canvasRenderer?.getCanvas(); + if (previous?.width === width && previous?.height === height) { + this.canvasRenderer.clearRect(0, 0, width, height); + } else { + // not resized: a renderer's resize is announced to the whole + // game, whose own renderer would take the canvas's size + this.canvasRenderer?.destroy(); + this.canvasRenderer = new CanvasRenderer({ + canvas: createCanvas(width, height), + width, + height, + transparent: true, + }); + } + /** @ignore */ + this._preRenderX = x; + /** @ignore */ + this._preRenderY = y; + /** + * whether a tile changed at runtime can be redrawn in its cell alone, + * overlapping no other: an orthogonal map whose tiles all fit their + * cell, with no offset. Anywhere else the layer is baked again + * @ignore + */ + this._preRenderInPlace = + this.orientation === "orthogonal" && + this.maxTileSize.width <= this.tilewidth && + this.maxTileSize.height <= this.tileheight && + !offset; + /** @ignore */ + this._preRenderStale = false; + /** + * whether the baked canvas changed since the layer last drew it: the + * renderer drawing it keeps a copy (a texture on the GPU, a tinted + * image on Canvas) to refresh + * @ignore + */ + this._preRenderChanged = true; + + this.canvasRenderer.save(); + this.canvasRenderer.translate(-x, -y); + renderer.drawTileLayer( + this.canvasRenderer, + this, + new Rect(x, y, width, height), + ); + this.canvasRenderer.restore(); + } + + /** + * Bring the baked canvas up to date with the tile at (x, y), just set or + * cleared: redrawn in its cell where that is exact, else baked again + * before the layer next draws + * @param {number} x - the tile's column + * @param {number} y - the tile's row + * @ignore + * @internal + */ + _preRenderCell(x, y) { + if (this.preRender !== true || this.canvasRenderer === undefined) { + return; + } + if (this._preRenderInPlace !== true) { + this._preRenderStale = true; + return; + } + const canvas = this.canvasRenderer; + canvas.save(); + canvas.translate(-this._preRenderX, -this._preRenderY); + canvas.clearRect( + x * this.tilewidth, + y * this.tileheight, + this.tilewidth, + this.tileheight, + ); + const tile = this.cellAt(x, y, false); + if (tile !== null) { + this.getRenderer().drawTile(canvas, x, y, tile); + } + canvas.restore(); + this._preRenderChanged = true; + } + /** * Resolve `this.renderMode` to one of "shader" / "prerender" / "perTile" * based on eligibility checks and user/world hints. Emits a single @@ -599,6 +715,7 @@ export default class TMXLayer extends Renderable { } this.dataVersion++; this.isDirty = true; + this._preRenderCell(x, y); return tile; } @@ -691,17 +808,9 @@ export default class TMXLayer extends Renderable { if (this.cachedTile !== null) { this.cachedTile[slot] = null; } - // erase the corresponding area in the canvas - if (this.preRender) { - this.canvasRenderer.clearRect( - x * this.tilewidth, - y * this.tileheight, - this.tilewidth, - this.tileheight, - ); - } this.dataVersion++; this.isDirty = true; + this._preRenderCell(x, y); } /** @@ -725,6 +834,18 @@ export default class TMXLayer extends Renderable { * @internal */ draw(renderer, rect) { + // a tile changed since the layer was baked, where it could not be + // redrawn alone + if (this._preRenderStale === true && this.canvasRenderer !== undefined) { + this._bakePreRender(); + } + // the baked canvas changed: refresh the renderer's copy of it, its + // texture on the GPU or a tinted image on Canvas + if (this._preRenderChanged === true && this.canvasRenderer !== undefined) { + this._preRenderChanged = false; + this.canvasRenderer.renderTarget.invalidate(renderer); + renderer.cache.tinted?.delete(this.canvasRenderer.getCanvas()); + } // dispatch to the active renderer — picks shader / preRender / perTile // based on `this.renderMode` and the renderer's capabilities renderer.drawTileLayer(this, rect); diff --git a/packages/melonjs/src/level/tiled/TMXObject.js b/packages/melonjs/src/level/tiled/TMXObject.js index 7e8427ed69..a760c29947 100644 --- a/packages/melonjs/src/level/tiled/TMXObject.js +++ b/packages/melonjs/src/level/tiled/TMXObject.js @@ -347,6 +347,45 @@ export default class TMXObject { : undefined; } + /** + * An ellipse object on an isometric map, projected with the map as its + * other shapes are: the ellipse inside the object's box, inside the box + * as projected. A circle stays an ellipse, level on screen, its centre + * and radii projected here. Any other ellipse comes out tilted, which an + * Ellipse cannot be: it becomes a polygon around it, in the object's own + * space, which parseTMXShapes then projects with the other shapes. + * @returns {Ellipse|Polygon} the shape + */ + #isoEllipse() { + const rx = this.width / 2; + const ry = this.height / 2; + if (rx === ry) { + // its centre, turned with the object about the object's position + const cos = Math.cos(this.rotation); + const sin = Math.sin(this.rotation); + const cx = rx * cos - ry * sin; + const cy = rx * sin + ry * cos; + // a circle of radius r projects to r * sqrt(2) across, times the + // tile ratio, and r / sqrt(2) down + return ellipsePool.get( + (cx - cy) * this.#isoRatio, + (cx + cy) / 2, + this.width * Math.SQRT2 * this.#isoRatio, + this.width * Math.SQRT1_2, + ); + } + // as many points as an ellipse takes to collide as a polygon + const segments = 16; + const points = []; + for (let i = 0; i < segments; i++) { + const angle = (i * 2 * Math.PI) / segments; + points.push( + vector2dPool.get(rx + rx * Math.cos(angle), ry + ry * Math.sin(angle)), + ); + } + return polygonPool.get(0, 0, points).rotate(this.rotation); + } + /** * parses the TMX shape definition and returns a corresponding array of shape objects * @private @@ -357,11 +396,15 @@ export default class TMXObject { switch (this.shapeType) { case "ellipse": - shapes.push( - ellipsePool - .get(this.width / 2, this.height / 2, this.width, this.height) - .rotate(this.rotation), - ); + if (this.orientation === "isometric" && this.gid === null) { + shapes.push(this.#isoEllipse()); + } else { + shapes.push( + ellipsePool + .get(this.width / 2, this.height / 2, this.width, this.height) + .rotate(this.rotation), + ); + } break; case "capsule": { diff --git a/packages/melonjs/src/level/tiled/TMXTileMap.js b/packages/melonjs/src/level/tiled/TMXTileMap.js index 0ccc2a628d..996dfd206e 100644 --- a/packages/melonjs/src/level/tiled/TMXTileMap.js +++ b/packages/melonjs/src/level/tiled/TMXTileMap.js @@ -1,4 +1,5 @@ import { warning } from "../../lang/console.js"; +import { getImage } from "../../loader/loader.js"; import { vector2dPool } from "../../math/vector2d.ts"; import { collision } from "../../physics/collision.js"; import Container from "../../renderable/container.js"; @@ -22,6 +23,7 @@ import TMXTilesetGroup from "./TMXTilesetGroup.js"; import { applyObjectOpacity, applyTMXProperties, + applyTransparentColor, parseTintColor, propagateBlendMode, resolveEmbeddedImage, @@ -87,7 +89,14 @@ function readImageLayer(map, data, z) { Object.assign( { name: data.name, - image: data.image, + // its transparent color applied, when Tiled gives one + image: + typeof data.transparentcolor === "string" + ? (applyTransparentColor( + getImage(data.image), + data.transparentcolor, + ) ?? data.image) + : data.image, ratio: vector2dPool.get(ratioX, ratioY), tint: parseTintColor(data.tintcolor), z: z, @@ -595,6 +604,11 @@ export default class TMXTileMap { } } + // the layers the cameras draw it on, from a custom property + if (typeof settings.visibilityLayer === "number") { + obj.visibilityLayer = settings.visibilityLayer; + } + // propagate group blend mode to child objects propagateBlendMode(obj, group.blendMode); diff --git a/packages/melonjs/src/level/tiled/TMXTileset.js b/packages/melonjs/src/level/tiled/TMXTileset.js index e739dbb2e3..d5e5e521f2 100644 --- a/packages/melonjs/src/level/tiled/TMXTileset.js +++ b/packages/melonjs/src/level/tiled/TMXTileset.js @@ -7,7 +7,7 @@ import { getBasename, getExtension } from "../../utils/file.ts"; import { createCanvas } from "../../video/canvas_factory.js"; import { TextureAtlas } from "../../video/texture/atlas.js"; import { buildFlipTransform } from "./TMXTile.js"; -import { resolveEmbeddedImage } from "./TMXUtils.js"; +import { applyTransparentColor, resolveEmbeddedImage } from "./TMXUtils.js"; // shared scratch matrix for flip-transform construction in drawTileRaw — avoids // per-call allocation in the hot path. Single-threaded JS means it's safe to @@ -429,7 +429,10 @@ export default class TMXTileset { // store per-tile image (collection of images tileset) if (tile.image) { - const image = getImage(tile.image); + const image = applyTransparentColor( + getImage(tile.image), + tile.transparentcolor, + ); if (!image) { throw new Error( `melonJS: '${tile.image}' file for tile '${tileId + this.firstgid}' not found!`, @@ -490,8 +493,11 @@ export default class TMXTileset { * @internal */ _initAtlas(tileset) { - // get the global tileset texture - this.image = getImage(tileset.image); + // get the global tileset texture, its transparent color applied + this.image = applyTransparentColor( + getImage(tileset.image), + tileset.transparentcolor, + ); if (!this.image) { throw new Error( @@ -757,8 +763,8 @@ export default class TMXTileset { this.tileheight, dx, dy, - dw + renderer.uvOffset, - dh + renderer.uvOffset, + dw, + dh, ); } @@ -870,8 +876,8 @@ export default class TMXTileset { this.tileheight, dx, dy, - dw + renderer.uvOffset, - dh + renderer.uvOffset, + dw, + dh, ); } diff --git a/packages/melonjs/src/level/tiled/TMXUtils.js b/packages/melonjs/src/level/tiled/TMXUtils.js index cdfc016f8a..7bec144d11 100644 --- a/packages/melonjs/src/level/tiled/TMXUtils.js +++ b/packages/melonjs/src/level/tiled/TMXUtils.js @@ -2,6 +2,7 @@ import { imgList } from "../../loader/cache.js"; import { colorPool } from "../../math/color.ts"; import { decode, decodeBase64Image } from "../../utils/decode.ts"; import { xmlToObject } from "../../utils/xml.ts"; +import { createCanvas } from "../../video/canvas_factory.js"; // counter for generating unique cache keys for embedded images let embeddedImageId = 0; @@ -119,6 +120,79 @@ export function parseTintColor(tintcolor) { return undefined; } +// the images a transparent color was applied to: per image, the result +// for each color, so tilesets sharing an image share one copy +const keyedImages = new WeakMap(); +// an image that cannot be read is warned about once +let warnedUnreadable = false; + +/** + * An image with Tiled's transparent color applied: every pixel of exactly + * that color made transparent, as Tiled draws it (the `trans` attribute of + * an ``, `transparentcolor` in JSON). Done once, on a copy of the + * image, cached per image and color; the image itself is left untouched. + * An image that cannot be read back, a compressed texture or one from + * another origin loaded without CORS, is returned as it is, with a warning. + * @ignore + * @internal + * @param {HTMLImageElement|HTMLCanvasElement|ImageBitmap} image - the image + * @param {string} [color] - the color, `"#ff00ff"` or `"ff00ff"` + * @returns {HTMLImageElement|HTMLCanvasElement|ImageBitmap} the image with + * that color transparent, or the image itself when there is no color + */ +export function applyTransparentColor(image, color) { + if (typeof color !== "string" || color === "" || !image) { + return image; + } + // #RRGGBB, or #AARRGGBB whose alpha says nothing here + const hex = color.replace(/^#/, "").slice(-6).toLowerCase(); + if (!/^[\da-f]{6}$/.test(hex)) { + return image; + } + let byColor = keyedImages.get(image); + if (byColor === undefined) { + byColor = new Map(); + keyedImages.set(image, byColor); + } + let keyed = byColor.get(hex); + if (keyed !== undefined) { + return keyed; + } + const width = image.naturalWidth || image.width; + const height = image.naturalHeight || image.height; + const canvas = createCanvas(width, height); + const context = canvas.getContext("2d", { willReadFrequently: true }); + let pixels; + try { + // a compressed texture cannot be drawn on a canvas, and an image + // from another origin cannot be read back without CORS + context.drawImage(image, 0, 0); + pixels = context.getImageData(0, 0, width, height); + } catch { + if (!warnedUnreadable) { + warnedUnreadable = true; + console.warn( + 'melonJS: a Tiled transparent color could not be applied to an image that cannot be read back (a compressed texture, or an image from another origin: load it with `loader.setOptions({ crossOrigin: "anonymous" })` from a server sending CORS headers); it is drawn as it is', + ); + } + byColor.set(hex, image); + return image; + } + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + const data = pixels.data; + for (let i = 0; i < data.length; i += 4) { + if (data[i] === r && data[i + 1] === g && data[i + 2] === b) { + data[i + 3] = 0; + } + } + context.putImageData(pixels, 0, 0); + keyed = canvas; + byColor.set(hex, keyed); + return keyed; +} + // pre-compiled regex for #ARGB → #RGBA color conversion // matches #ARGB (5 chars) or #AARRGGBB (9 chars) const SHORT_ARGB = /^#([\da-fA-F])([\da-fA-F]{3})$/; @@ -232,6 +306,19 @@ function coerceTMXValue(name, type, raw) { return raw; } +/** + * Keep the transparent color of an `` (its `trans` attribute, as + * `RRGGBB`) on the object it belongs to, under the key the JSON format uses + * @ignore + * @internal + */ +function keepTransparentColor(obj) { + const trans = obj.image.trans; + if (typeof trans === "string" && trans !== "") { + obj.transparentcolor = trans.startsWith("#") ? trans : `#${trans}`; + } +} + /** * Flatten image child object into parent (used by tile and tileset). * Moves source → image, width → imagewidth, height → imageheight. @@ -243,6 +330,7 @@ function flattenImage(obj) { if (obj.image) { obj.imagewidth = obj.image.width; obj.imageheight = obj.image.height; + keepTransparentColor(obj); if (obj.image.source) { obj.image = obj.image.source; } else if (obj.image.data) { @@ -314,6 +402,7 @@ function normalizeTMX(obj, item, parse) { layer.type = nodeName === "layer" ? "tilelayer" : nodeName; if (layer.image) { // flattenImage equivalent for image layers + keepTransparentColor(layer); if (layer.image.source) { layer.image = layer.image.source; } else if (layer.image.data) { diff --git a/packages/melonjs/src/level/tiled/renderer/TMXObliqueRenderer.js b/packages/melonjs/src/level/tiled/renderer/TMXObliqueRenderer.js index e20c402516..069edd7cf7 100644 --- a/packages/melonjs/src/level/tiled/renderer/TMXObliqueRenderer.js +++ b/packages/melonjs/src/level/tiled/renderer/TMXObliqueRenderer.js @@ -200,14 +200,17 @@ export default class TMXObliqueRenderer extends TMXOrthogonalRenderer { // add margin for oversize tiles startX = Math.max( Math.floor( - startX - (layer.maxTileSize.width - layer.tilewidth) / this.tilewidth, + startX - + Math.max(layer.maxTileSize.width - layer.tilewidth, 0) / + this.tilewidth, ), 0, ); startY = Math.max( Math.floor( startY - - (layer.maxTileSize.height - layer.tileheight) / this.tileheight, + Math.max(layer.maxTileSize.height - layer.tileheight, 0) / + this.tileheight, ), 0, ); diff --git a/packages/melonjs/src/level/tiled/renderer/TMXOrthogonalRenderer.js b/packages/melonjs/src/level/tiled/renderer/TMXOrthogonalRenderer.js index eee5a48c67..b277298211 100644 --- a/packages/melonjs/src/level/tiled/renderer/TMXOrthogonalRenderer.js +++ b/packages/melonjs/src/level/tiled/renderer/TMXOrthogonalRenderer.js @@ -89,8 +89,14 @@ export default class TMXOrthogonalRenderer extends TMXRenderer { // get top-left and bottom-right tile position const start = this.pixelToTileCoords( - Math.max(rect.pos.x - (layer.maxTileSize.width - layer.tilewidth), 0), - Math.max(rect.pos.y - (layer.maxTileSize.height - layer.tileheight), 0), + Math.max( + rect.pos.x - Math.max(layer.maxTileSize.width - layer.tilewidth, 0), + 0, + ), + Math.max( + rect.pos.y - Math.max(layer.maxTileSize.height - layer.tileheight, 0), + 0, + ), vector2dPool.get(), ).floorSelf(); diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index f22b72327a..0b68459701 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -1578,11 +1578,20 @@ export default class Container extends Renderable { const skipsFloating = viewport ? !(viewport.drawsFloating ?? viewport.isDefault) : false; + // the layers this camera draws: every layer without a camera, or for + // a viewport that has no mask + const cullMask = viewport?.cullMask ?? -1; const children = this.getChildren(); for (let i = children.length, obj; i--, (obj = children[i]); ) { const isFloating = obj.floating === true; + // on no layer this camera draws: left out, and with it whatever + // it contains + if (((obj.visibilityLayer ?? 1) & cullMask) === 0) { + continue; + } + if (obj.inViewport || isFloating) { if (isFloating && skipsFloating && !obj.visibleInAllCameras) { continue; diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index 88ca93c7cf..dd8356df49 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -287,6 +287,36 @@ export default class Renderable extends Rect { */ this.visibleInAllCameras = false; + /** + * The layers this renderable belongs to, as bits (1, 2, 4, 8... up to + * 32 of them). A camera draws it only if it shares a bit with the + * camera's {@link Camera2d#cullMask}: by default every renderable is on + * layer 1 and every camera draws every layer, so it shows in all of + * them. Put something on a layer a camera leaves out to keep it off that + * camera only, such as the particles off a minimap. A container left + * out is left out with everything inside it (the world included), and + * a renderable on no layer, `0`, is drawn by no camera. + *

+ * It decides what a camera draws, and nothing else: the renderable + * still updates, collides and receives pointer events. A + * {@link Light2d} a camera leaves out, itself or a container it is in, + * neither glows, nor cuts its hole in the darkness, nor lights + * normal-mapped sprites in that camera (a `Light3d` lights the meshes + * of every camera). A + * floating renderable must also pass the camera's floating rule (see + * {@link Renderable#visibleInAllCameras}). On an object of a Tiled map + * it can be set as an `int` custom property of the object. + * @type {number} + * @default 1 + * @see Camera2d#cullMask + * @example + * // particles on their own layer, and a minimap that leaves it out + * const PARTICLES = 2; + * emitter.visibilityLayer = PARTICLES; + * minimap.cullMask = ~PARTICLES; + */ + this.visibilityLayer = 1; + /** * When enabled, an object container will automatically apply * any defined transformation before calling the child draw method. @@ -1673,6 +1703,7 @@ export function resetRenderableState(renderable) { renderable.isRenderable = true; renderable.isKinematic = true; renderable.visibleInAllCameras = false; + renderable.visibilityLayer = 1; renderable.alwaysUpdate = false; renderable.updateWhenPaused = false; renderable.isPersistent = false; diff --git a/packages/melonjs/src/state/stage.ts b/packages/melonjs/src/state/stage.ts index 72a6d543bc..ccd0493bd8 100644 --- a/packages/melonjs/src/state/stage.ts +++ b/packages/melonjs/src/state/stage.ts @@ -7,6 +7,15 @@ import type World from "./../physics/world.js"; import { emit, STAGE_RESET } from "../system/event.ts"; import type Renderer from "./../video/renderer.js"; +/** + * what a camera's cull mask is checked against, up a renderable's ancestors + * @ignore + */ +type Layered = { + visibilityLayer?: number; + ancestor?: Layered | null; +}; + interface StageSettings { cameras: Camera2d[]; /** @@ -82,6 +91,14 @@ export default class Stage { */ _activeLights: Set; + /** + * The active lights the camera being drawn draws, refilled by + * {@link Stage#_lightsFor} for each camera (never reallocated). + * @ignore + * @internal + */ + _cameraLights: Light2d[]; + /** * Internal set of active 3D lights, auto-populated by `Light3d`'s * `onActivateEvent` / `onDeactivateEvent` hooks. Read by the lit mesh @@ -151,6 +168,7 @@ export default class Stage { this.cameras = new Map(); this.lights = new Map(); this._activeLights = new Set(); + this._cameraLights = []; this._activeLights3d = new Set(); this.ambientLight = new Color(0, 0, 0, 0); this.ambientLightingColor = new Color(0, 0, 0, 1); @@ -358,6 +376,41 @@ export default class Stage { }); } + /** + * The active lights a camera draws: those on a layer in its + * {@link Camera2d#cullMask}, in containers that are too, as the camera + * leaves out a container with everything in it. A light it leaves out + * neither glows, nor cuts its hole in the darkness, nor lights + * normal-mapped sprites in that camera. The list is reused, so read it + * before the next camera asks. + * @param camera - the camera being drawn + * @returns the lights that camera draws + * @ignore + * @internal + */ + _lightsFor(camera: Camera2d): Light2d[] { + const mask = camera.cullMask; + const lights = this._cameraLights; + lights.length = 0; + for (const light of this._activeLights) { + let shown = true; + for ( + let node: Layered | null | undefined = light; + node !== undefined && node !== null; + node = node.ancestor + ) { + if (((node.visibilityLayer ?? 1) & mask) === 0) { + shown = false; + break; + } + } + if (shown) { + lights.push(light); + } + } + return lights; + } + /** * Draw the stage's ambient-light overlay with cutouts for each active * light. Called from each `Camera2d` inside its post-effect FBO bracket — @@ -420,13 +473,15 @@ export default class Stage { const y = ty - margin; const width = camera.width / camera.zoom + margin * 2; const height = camera.height / camera.zoom + margin * 2; + // the lights this camera draws (see `cullMask`) + const lights = this._lightsFor(camera); // any light with a soft edge: the renderer draws the darkness with // every light's falloff - for (const light of this._activeLights) { + for (const light of lights) { if (light.softness > 0) { r.drawAmbientLight( this.ambientLight, - this._activeLights, + lights, x, y, width, @@ -440,9 +495,9 @@ export default class Stage { } // punch out each active light's visible area so the ambient fill // doesn't darken what the light is illuminating - this._activeLights.forEach((light) => { + for (const light of lights) { r.setMask(light.getVisibleArea(), true); - }); + } r.setColor(this.ambientLight); r.fillRect(x, y, width, height); r.clearMask(); diff --git a/packages/melonjs/src/system/timer.ts b/packages/melonjs/src/system/timer.ts index a219e2934a..0f2dce46b6 100644 --- a/packages/melonjs/src/system/timer.ts +++ b/packages/melonjs/src/system/timer.ts @@ -11,6 +11,14 @@ import { STATE_RESUME, } from "./event.js"; +/** + * The display frame times {@link Timer#syncToDisplay} counts exactly, as + * multiples of `1000 / maxfps`: 15, 20, 30, 60, 120, 180 and 240 Hz at the + * default `maxfps` of 60. + * @ignore + */ +const STEP_RATIOS = [4, 3, 2, 1, 1 / 2, 1 / 3, 1 / 4]; + /** * a Timer class to manage timing related function (FPS, Game Tick, Time...) * @see {@link timer} the default global timer instance @@ -20,6 +28,33 @@ class Timer { fps: number; maxfps: number; interpolation: boolean; + syncToDisplay: boolean; + /** + * the display's frame time, learned by {@link Timer#syncToDisplay} + * @ignore + * @internal + */ + _displayFrame: number; + /** + * the time {@link Timer#syncToDisplay} rounded off, carried to the next + * frames (less than a display frame either way) + * @ignore + * @internal + */ + _frameLeftover: number; + /** + * how many frames running were far from the learned display frame, and + * close to one another: a display whose rate changed + * @ignore + * @internal + */ + _displayRun: number; + /** + * the total time of those frames + * @ignore + * @internal + */ + _displayRunTotal: number; framecount: number; framedelta: number; last: number; @@ -67,6 +102,36 @@ class Timer { */ this.interpolation = false; + /** + * Count the time each frame took in whole display frames.
+ * The browser reports frame times that jitter around the display's + * refresh, by up to a millisecond either way. When the display runs + * as fast as the game updates (60 Hz for 60 updates a second), that + * jitter can leave a frame with no update and give the next one two, + * and whatever moves on screen stutters. Counted in whole display + * frames, the updates fall in a steady pattern: one every frame at + * 60 Hz, one every other frame at 120 Hz. The time rounded off is + * carried to the next frame, so the game keeps its speed.
+ * A display within 2% of `maxfps` (60 by default), or of 2, 3 or 4 + * times it, or a half, a third or a quarter of it (120, 180, 240, 30, + * 20 or 15 Hz), is counted at exactly that rate, so a 59.94 Hz screen + * runs the game 0.1% slower than the wall clock. The display's rate + * is learned as the game runs, and learned again when it changes. + * Leave it off when the updates must keep to the wall clock exactly.
+ * Set by the `syncToDisplay` application setting when the + * {@link Application} is initialized. + * @see ApplicationSettings.syncToDisplay + * @default false + * @example + * // turned on or off while the game runs, as the setting does at start + * timer.syncToDisplay = true; + */ + this.syncToDisplay = false; + this._displayFrame = 0; + this._frameLeftover = 0; + this._displayRun = 0; + this._displayRunTotal = 0; + //hold element to display fps this.framecount = 0; this.framedelta = 0; @@ -120,6 +185,9 @@ class Timer { this.framecount = 0; this.step = 1000 / this.maxfps; this.minstep = (1000 / this.maxfps) * 1.25; + // the display is the same; only the time it was owed is dropped + this._frameLeftover = 0; + this._displayRun = 0; } /** @@ -240,6 +308,10 @@ class Timer { this.delta = 0; } + if (this.syncToDisplay) { + this.delta = this.countDisplayFrames(this.delta); + } + // get the game tick this.tick = this.delta > this.minstep && this.interpolation @@ -262,6 +334,74 @@ class Timer { this.updateTimers(); } + /** + * The time a frame took, counted in whole display frames (see + * {@link Timer#syncToDisplay}). + * @param delta - the time the frame took, in milliseconds + * @returns that time as a whole number of display frames + * @ignore + * @internal + */ + countDisplayFrames(delta: number) { + const step = this.step; + if (!(this._displayFrame > 0)) { + this._displayFrame = step; + } + // the display's frame time, learned from the frames of about its + // length + const learned = this._displayFrame; + if (delta > learned / 1.5 && delta < learned * 1.5) { + this._displayFrame += (delta - learned) * 0.05; + this._displayRun = 0; + } else if (delta > 0) { + // a frame far from it: a dropped one (longer), the partial first + // frame after a reset (shorter), or a display whose rate changed, + // moved to another screen or slowed down on battery. Frames of the + // same length several times running are the display's new rate; a + // shorter frame is never a dropped one, so it is taken sooner + const mean = + this._displayRun > 0 ? this._displayRunTotal / this._displayRun : 0; + if (this._displayRun > 0 && Math.abs(delta - mean) < mean * 0.2) { + this._displayRun++; + this._displayRunTotal += delta; + } else { + this._displayRun = 1; + this._displayRunTotal = delta; + } + if (this._displayRun >= (delta < learned ? 2 : 6)) { + this._displayFrame = this._displayRunTotal / this._displayRun; + this._displayRun = 0; + } + } + // a display in step with the updates counts exactly that multiple or + // fraction of a step, so the updates fall in a fixed pattern + let frame = this._displayFrame; + for (const ratio of STEP_RATIOS) { + const exact = step * ratio; + if (Math.abs(frame - exact) < exact * 0.02) { + frame = exact; + break; + } + } + // whole frames: each frame counted by its own length, so one a little + // early or late is still one frame. What is rounded off is carried, + // and counted once it adds up to a whole frame either way, which + // brings the carry back near nothing: the game keeps its speed, and a + // carry left near half a frame cannot flip the count on the jitter + const learnedFrame = this._displayFrame; + let frames = Math.max(0, Math.round(delta / learnedFrame)); + let leftover = this._frameLeftover + delta - frames * learnedFrame; + if (leftover >= learnedFrame) { + frames++; + leftover -= learnedFrame; + } else if (leftover <= -learnedFrame && frames > 0) { + frames--; + leftover += learnedFrame; + } + this._frameLeftover = leftover; + return frames * frame; + } + /** * clear Timers * @ignore diff --git a/packages/melonjs/src/video/buffer/vertex.js b/packages/melonjs/src/video/buffer/vertex.js index bf54d37f72..013f4f57ea 100644 --- a/packages/melonjs/src/video/buffer/vertex.js +++ b/packages/melonjs/src/video/buffer/vertex.js @@ -1,3 +1,5 @@ +import { FRAME_UNBOUNDED } from "../gpu/frame.js"; + /** * A fixed-size vertex array buffer for batching vertex data. * Renderer-agnostic — stores vertex data in typed arrays (Float32/Uint32). @@ -5,7 +7,6 @@ * @ignore * @internal */ - export default class VertexArrayBuffer { constructor(vertexSize, maxVertex) { // the size of one vertex in floats @@ -53,25 +54,20 @@ export default class VertexArrayBuffer { } /** - * push a new vertex to the buffer + * push a new vertex to the buffer: the 6-float layout of the primitive + * batcher (a shape's position, its normal or UV, and a packed color). + * Sprites have their own, wider layout: see {@link pushSprite}. * @param {number} x - x position * @param {number} y - y position * @param {number} z - z position (per-renderable depth; 0 under ortho) * @param {number} u - texture U coordinate (or aNormal.x for primitives) * @param {number} v - texture V coordinate (or aNormal.y for primitives) * @param {number} tint - tint color in UINT32 (argb) format - * @param {number} [textureId] - texture unit index for multi-texture batching - * @param {number} [normalTextureId] - paired normal-map texture unit index, or `-1` for unlit quads - * @param {number} [shininess] - specular exponent for the GL lit-quad layout, - * which is 9 floats; 0 (matte) when omitted. NOTE the WebGPU lit layout is 8 - * floats and carries its exponent in slot 7 instead, the slot whose omitted - * default here is the `-1` unlit sentinel — that batcher therefore always - * passes an explicit value through its own `pushQuadVertices` override and - * never relies on this default. + * @returns {VertexArrayBuffer} this * @ignore * @internal */ - push(x, y, z, u, v, tint, textureId, normalTextureId, shininess) { + push(x, y, z, u, v, tint) { const offset = this.vertexCount * this.vertexSize; this.bufferF32[offset] = x; @@ -80,26 +76,57 @@ export default class VertexArrayBuffer { this.bufferF32[offset + 3] = u; this.bufferF32[offset + 4] = v; this.bufferU32[offset + 5] = tint; - if (this.vertexSize > 6) { - this.bufferF32[offset + 6] = textureId || 0; - if (this.vertexSize > 7) { - // `aNormalTextureId`: -1 (sentinel for unlit) is the safe - // default when the caller doesn't supply one. Writing 0 - // here would let the fragment shader's lit path activate - // on unlit quads with whatever normal-map happened to be - // bound at unit 0 — visible as garbage hemispheric - // shading on every sprite. - this.bufferF32[offset + 7] = - typeof normalTextureId === "number" ? normalTextureId : -1; - if (this.vertexSize > 8) { - // `aShininess` on the GL lit-quad layout, which carries - // both the normal-map slot and the exponent. 0 is matte, - // and matte is what every sprite that never opts in must - // get — so the fallback here is 0, not the -1 sentinel - // above (a negative exponent would make `pow` explode). - this.bufferF32[offset + 8] = - typeof shininess === "number" ? shininess : 0; - } + + this.vertexCount++; + + return this; + } + + /** + * push a sprite vertex: the quad batchers' layout, whose fifth attribute + * (floats 7 and 8, after the texture slot) is the UV of the quad's + * opposite corner. With the vertex's own UV, that is the frame of the + * texture the quad draws, so the sprite shaders can keep their samples + * inside it ({@link FRAME_UNBOUNDED} for a quad that has none). It sits + * at the same place in every sprite layout, so an effect's program reads + * it on any of them. Any field a layout adds after it (a lit layout's + * normal map slot and shininess) goes in `extra0` and `extra1`, in + * layout order. Every field is written as given, so a caller passes all + * of its layout's: the lit batchers write `-1` for "no normal map" and + * `0` for a matte sprite themselves. + * @param {number} x - x position + * @param {number} y - y position + * @param {number} z - depth + * @param {number} u - texture u + * @param {number} v - texture v + * @param {number} tint - tint color in UINT32 (argb) format + * @param {number} textureId - the texture slot + * @param {number} frameU - u of the quad's opposite corner + * @param {number} frameV - v of the quad's opposite corner + * @param {number} [extra0] - the layout's first field after the slot + * @param {number} [extra1] - the layout's second field after the slot + * @returns {VertexArrayBuffer} this + * @ignore + * @internal + */ + pushSprite(x, y, z, u, v, tint, textureId, frameU, frameV, extra0, extra1) { + const size = this.vertexSize; + const offset = this.vertexCount * size; + const f32 = this.bufferF32; + + f32[offset] = x; + f32[offset + 1] = y; + f32[offset + 2] = z; + f32[offset + 3] = u; + f32[offset + 4] = v; + this.bufferU32[offset + 5] = tint; + f32[offset + 6] = textureId; + f32[offset + 7] = frameU; + f32[offset + 8] = frameV; + if (size > 9) { + f32[offset + 9] = extra0; + if (size > 10) { + f32[offset + 10] = extra1; } } @@ -108,6 +135,103 @@ export default class VertexArrayBuffer { return this; } + /** + * push the four corners of a sprite quad (see {@link pushSprite}): + * top left, top right, bottom left, bottom right, each carrying the UV + * of the corner opposite it. A quad with no frame of its own (a + * repeating pattern, a whole render target) carries the unbounded mark + * instead, and its samples are left alone. + * @param {{x: number, y: number, z: number}} c0 - top left corner + * @param {{x: number, y: number, z: number}} c1 - top right corner + * @param {{x: number, y: number, z: number}} c2 - bottom left corner + * @param {{x: number, y: number, z: number}} c3 - bottom right corner + * @param {number} u0 - left u + * @param {number} v0 - top v + * @param {number} u1 - right u + * @param {number} v1 - bottom v + * @param {number} tint - tint color in UINT32 (argb) format + * @param {number} textureId - the texture slot + * @param {boolean} framed - false when the quad has no frame to keep to + * @param {number} [extra0] - the layout's first field after the slot + * @param {number} [extra1] - the layout's second field after the slot + * @returns {VertexArrayBuffer} this + * @ignore + * @internal + */ + pushSpriteQuad( + c0, + c1, + c2, + c3, + u0, + v0, + u1, + v1, + tint, + textureId, + framed, + extra0, + extra1, + ) { + const fu0 = framed ? u0 : FRAME_UNBOUNDED; + const fv0 = framed ? v0 : FRAME_UNBOUNDED; + const fu1 = framed ? u1 : FRAME_UNBOUNDED; + const fv1 = framed ? v1 : FRAME_UNBOUNDED; + this.pushSprite( + c0.x, + c0.y, + c0.z, + u0, + v0, + tint, + textureId, + fu1, + fv1, + extra0, + extra1, + ); + this.pushSprite( + c1.x, + c1.y, + c1.z, + u1, + v0, + tint, + textureId, + fu0, + fv1, + extra0, + extra1, + ); + this.pushSprite( + c2.x, + c2.y, + c2.z, + u0, + v1, + tint, + textureId, + fu1, + fv0, + extra0, + extra1, + ); + this.pushSprite( + c3.x, + c3.y, + c3.z, + u1, + v1, + tint, + textureId, + fu0, + fv0, + extra0, + extra1, + ); + return this; + } + /** * push a new vertex with all-float data to the buffer * @param {ArrayLike} data - float values for one vertex diff --git a/packages/melonjs/src/video/canvas/canvas_renderer.js b/packages/melonjs/src/video/canvas/canvas_renderer.js index 6fbbb849b9..6169d81a96 100644 --- a/packages/melonjs/src/video/canvas/canvas_renderer.js +++ b/packages/melonjs/src/video/canvas/canvas_renderer.js @@ -57,11 +57,6 @@ export default class CanvasRenderer extends Renderer { */ this.cache = new TextureCache(this); - if (this.settings.textureSeamFix !== false && !this.settings.antiAlias) { - // enable the tile texture seam fix with the canvas renderer - this.uvOffset = 1; - } - // set the renderer type this.type = "CANVAS"; @@ -107,6 +102,33 @@ export default class CanvasRenderer extends Renderer { off(GAME_RESET, this.onGameReset); } + /** + * Resize the canvas, keeping the drawing state this renderer set on it + * (image smoothing, blend mode, color or gradient, line width, join and + * dash). + * Resizing a canvas resets its 2D context to the defaults, image + * smoothing on among them: without this, a game created with + * `antiAlias: false` drew smoothed from its first resize on, with seams + * between its tiles where smoothing took in the spacing of their tileset. + * @param {number} width - new width of the canvas + * @param {number} height - new height of the canvas + */ + resize(width, height) { + const canvas = this.getCanvas(); + if (width === canvas.width && height === canvas.height) { + return; + } + const lineWidth = this.lineWidth; + const lineJoin = this.lineJoin; + super.resize(width, height); + this.renderTarget.setAntiAlias(this.settings.antiAlias); + this.setBlendMode(this.currentBlendMode); + this.setColor(this.renderState.currentGradient ?? this.currentColor); + this.lineWidth = lineWidth; + this.lineJoin = lineJoin; + this.getContext().setLineDash(this.renderState.lineDash); + } + /** * Reset context state */ diff --git a/packages/melonjs/src/video/effects/frame.js b/packages/melonjs/src/video/effects/frame.js index bed3f360fb..e9bf250ae2 100644 --- a/packages/melonjs/src/video/effects/frame.js +++ b/packages/melonjs/src/video/effects/frame.js @@ -5,24 +5,37 @@ * texture, and the texture goes on past that frame into the neighbouring * ones. An effect that samples away from the fragment it shades (a glow, an * outline, a blur, a shadow, a distortion) reads those neighbours along the - * frame's edge, and draws them. The quad batchers hand the effect the frame - * it draws, as `ME_frame` (its UV rect: left, top, right, bottom) and - * `ME_frame_half` (half a texel of its texture, in UV), through - * `ShaderEffect#_setFrameRect`. `frameTexel()` reads a texel of that frame: - * transparent outside it, and kept half a texel inside it, so filtering - * never reaches over the edge. Only where another frame could be: a side of - * the frame that is the texture's own edge is left unbounded, and so is a - * pass over a whole texture (a render target blit, a mesh). There, sampling - * is what it always was. + * frame's edge, and draws them. `frameTexel()` reads a texel of the frame + * the effect draws, `ME_frame` (its UV rect: left, top, right, bottom): + * transparent outside it, and kept half a texel (`ME_frame_half`, in UV) + * inside it, so filtering never reaches over the edge. Only where another + * frame could be: a side of the frame that is the texture's own edge is + * left unbounded, and so is a pass over a whole texture (a render target + * blit, a mesh). There, sampling is what it always was. + * + * On a sprite, `ME_frame` comes with the quad: every sprite vertex carries + * the UV of its quad's opposite corner (`aFrame`), and the effect's vertex + * stage turns it into the frame. Sprites drawn from different frames + * through the same effect so share one draw. Only `ME_frame_half`, which + * changes with the texture, is a uniform (`ShaderEffect#_setFrameTexel`). + * On a mesh, which carries no frame, `ME_frame` is a uniform, left + * unbounded. * @ignore */ /** - * the uniforms and `frameTexel()`, for a GLSL effect body + * the frame, the half texel and `frameTexel()`, for a GLSL effect body. The + * sprite program defines `ME_SPRITE_FRAME` and computes the varying in its + * vertex stage (see glsl_realization.js); a mesh hosting the body reads the + * uniform * @ignore */ export const FRAME_GLSL = ` +#ifdef ME_SPRITE_FRAME +varying vec4 ME_frame; +#else uniform vec4 ME_frame; +#endif uniform vec2 ME_frame_half; vec4 frameTexel(vec2 p) { vec2 inside = step(ME_frame.xy, p) * step(p, ME_frame.zw); @@ -33,7 +46,9 @@ vec4 frameTexel(vec2 p) { /** * the members to add to a WGSL effect's uniform struct, which `frameTexel()` - * reads through the effect's `fx` binding + * reads through the effect's `fx` binding. On a sprite the scaffold reads + * the frame from the quad instead of `fx.ME_frame` (see wgsl/scaffold.js), + * which a mesh hosting the body still reads * @ignore */ export const FRAME_WGSL_MEMBERS = ` diff --git a/packages/melonjs/src/video/effects/glsl_realization.js b/packages/melonjs/src/video/effects/glsl_realization.js index f0799e7816..fc29950a17 100644 --- a/packages/melonjs/src/video/effects/glsl_realization.js +++ b/packages/melonjs/src/video/effects/glsl_realization.js @@ -30,6 +30,9 @@ const SCREEN_TEXTURE_ANNOTATION = /\buniform\s+sampler2D\s+([A-Za-z_]\w*)\s*:\s*screen_texture(?:\((repeat|repeat-x|repeat-y|no-repeat)\))?\s*;/g; const SCREEN_UV_IDENTIFIER = /\bscreen_uv\b/; const NOISE_UV_IDENTIFIER = /\bnoise_uv\b/; +// a body built with frame.js's FRAME_GLSL, whose frame a sprite's vertex +// stage computes from the quad +const SPRITE_FRAME_SWITCH = /#ifdef\s+ME_SPRITE_FRAME\b/; /** * whether the body declares `name` itself (as a varying/uniform/attribute) — @@ -65,29 +68,40 @@ function parseShaderBuiltins(fragmentBody) { !hasOwnDeclaration(body, "screen_uv"); const noiseUV = NOISE_UV_IDENTIFIER.test(body) && !hasOwnDeclaration(body, "noise_uv"); - return { body, screenTextures, screenUV, noiseUV }; + const spriteFrame = SPRITE_FRAME_SWITCH.test(body); + return { body, screenTextures, screenUV, noiseUV, spriteFrame }; } /** * The effect's vertex shader: the stock quad template, or — when a builtin * varying is in play — a variant that additionally computes `screen_uv` - * (clip space → 0..1) and/or `noise_uv` (atlas UV → frame-local, fed by - * `_setNoiseUVRect` through the `ME_*` uniforms). Built deterministically - * from a template; user source is never rewritten. + * (clip space → 0..1), `noise_uv` (atlas UV → frame-local, fed by + * `_setNoiseUVRect` through the `ME_*` uniforms) and/or `ME_frame` (the + * frame the quad draws, from the vertex's own UV and `aFrame`, the + * opposite corner's; see frame.js). Built deterministically from a + * template; user source is never rewritten. * @ignore * @internal */ function buildEffectVertex(builtins) { - if (!builtins.screenUV && !builtins.noiseUV) { + if (!builtins.screenUV && !builtins.noiseUV && !builtins.spriteFrame) { return quadVertex; } return [ "attribute vec3 aVertex;", "attribute vec2 aRegion;", "attribute vec4 aColor;", + // declared, though unread, so `aFrame` takes the location it has in + // every sprite layout: the fifth (attributes are bound in order) + ...(builtins.spriteFrame + ? ["attribute float aTextureId;", "attribute vec2 aFrame;"] + : []), "uniform mat4 uProjectionMatrix;", "varying vec2 vRegion;", "varying vec4 vColor;", + ...(builtins.spriteFrame + ? ["uniform vec2 ME_frame_half;", "varying vec4 ME_frame;"] + : []), ...(builtins.screenUV ? ["varying vec2 screen_uv;"] : []), ...(builtins.noiseUV ? [ @@ -108,6 +122,23 @@ function buildEffectVertex(builtins) { " noise_uv = aRegion * (ME_size_img / ME_size_obj) - ME_offset / ME_size_obj;", ] : []), + ...(builtins.spriteFrame + ? [ + // a side on the texture's own edge (within a hundredth of + // a texel) has no frame past it and is left unbounded, as + // is a quad with no frame at all + " vec4 ME_rect = vec4(min(aRegion, aFrame), max(aRegion, aFrame));", + " vec2 ME_edge = ME_frame_half * 0.02;", + " ME_frame = vec4(", + " ME_rect.x <= ME_edge.x ? -1.0e6 : ME_rect.x,", + " ME_rect.y <= ME_edge.y ? -1.0e6 : ME_rect.y,", + " ME_rect.z >= 1.0 - ME_edge.x ? 1.0e6 : ME_rect.z,", + " ME_rect.w >= 1.0 - ME_edge.y ? 1.0e6 : ME_rect.w);", + " if (aFrame.x < -5.0e5) {", + " ME_frame = vec4(-1.0e6, -1.0e6, 1.0e6, 1.0e6);", + " }", + ] + : []), " vColor = vec4(aColor.bgr * aColor.a, aColor.a);", " vRegion = aRegion;", "}", @@ -131,6 +162,8 @@ export function buildGLSLProgram(fragmentBody) { // wrap the user's apply() with the texture-sampling boilerplate const fragment = [ + // the frame is the varying the vertex stage computes (frame.js) + ...(builtins.spriteFrame ? ["#define ME_SPRITE_FRAME"] : []), "uniform sampler2D uSampler;", "varying vec4 vColor;", "varying vec2 vRegion;", diff --git a/packages/melonjs/src/video/effects/shadereffect.js b/packages/melonjs/src/video/effects/shadereffect.js index bd2394aa6d..47d6f4d993 100644 --- a/packages/melonjs/src/video/effects/shadereffect.js +++ b/packages/melonjs/src/video/effects/shadereffect.js @@ -380,26 +380,29 @@ export default class ShaderEffect { this.enabled = true; } - // the frame a body declaring `ME_frame` keeps its samples inside (see - // frame.js): unbounded until a quad batcher hands it one. Recorded - // as well, for a mesh hosting the effect, which replays the record - // and has no frame to keep to + // a body keeping its samples inside the frame it draws (see + // frame.js). On a sprite the frame comes with the quad, and only its + // half texel is a uniform; a mesh hosting the effect reads `ME_frame` + // as a uniform too, and has no frame to keep to: it is recorded + // unbounded, for the mesh to replay (the WGSL struct keeps the member, + // which the sprite scaffold does not read) const hasFrame = typeof this._shader !== "undefined" - ? typeof this._shader.uniforms?.ME_frame !== "undefined" - : this.wgslRealization.hasUniform("ME_frame"); + ? typeof this._shader.uniforms?.ME_frame_half !== "undefined" + : this.wgslRealization.hasUniform("ME_frame_half"); if (hasFrame) { - /** - * @ignore - * @internal - */ - this._frameRect = new Float32Array(UNBOUNDED_FRAME); /** * @ignore * @internal */ this._frameHalf = new Float32Array(2); - this.setUniform("ME_frame", new Float32Array(UNBOUNDED_FRAME)); + this._uniformValues.set("ME_frame", new Float32Array(UNBOUNDED_FRAME)); + if (typeof this.wgslRealization !== "undefined") { + this.wgslRealization.setUniform( + "ME_frame", + new Float32Array(UNBOUNDED_FRAME), + ); + } this.setUniform("ME_frame_half", new Float32Array(2)); } @@ -460,12 +463,25 @@ export default class ShaderEffect { * vectors and matrices take an array, a `Float32Array`, or any object * exposing `toArray()` — which is every {@link Vector2d}, * {@link Vector3d}, {@link Color} and {@link Matrix3d}. + * + * Sprites drawn one after the other through the same effect share one + * draw call. A new value draws the sprites queued before it first, so + * each sprite draws with the values it was given; the value it already + * has changes nothing, and keeps the draw shared. * @example * // a scalar the body declares as `uniform float uStrength;` * fx.setUniform("uStrength", 0.5); * // a vec3 — an array, or anything with toArray() * fx.setUniform("uTint", [1.0, 0.82, 0.55]); * fx.setUniform("uOrigin", new me.Vector2d(0.5, 0.5)); + * @example + * // one outline shared by every enemy: drawn in a single call. Marked + * // shared, so the first enemy destroyed does not destroy it for the rest + * const outline = new me.OutlineEffect(renderer, { color: [1, 1, 1] }); + * outline.shared = true; + * for (const enemy of enemies) { + * enemy.addPostEffect(outline); + * } */ setUniform(name, value) { // forward whenever a live shader exists (WebGL mode): GLShader handles @@ -598,96 +614,56 @@ export default class ShaderEffect { } /** - * Hand a body declaring `ME_frame` the frame of the quad about to draw, - * so it keeps its samples inside it (see frame.js): on an atlas, the - * texture goes on past the frame into the neighbouring ones. A side of - * the frame that is the texture's own edge has no frame past it, and is - * left unbounded: there, sampling stays what it always was (the - * texture's wrap mode), so a standalone image, a pattern, or a frame on - * the outside of its atlas draws as it did. Called by the quad batchers - * per quad, each of which an effect draws on its own (a quad through an - * effect is drawn before the next one is queued); a no-op for any other - * body. - * @param {number} u0 - the frame's left UV coordinate - * @param {number} v0 - the frame's top UV coordinate - * @param {number} u1 - the frame's right UV coordinate - * @param {number} v1 - the frame's bottom UV coordinate + * Hand a body declaring `ME_frame` (see frame.js) the half texel of the + * texture the quad about to draw comes from, which `frameTexel()` keeps + * its samples inside the frame by. The frame itself comes with the quad: + * the effect's vertex stage computes it from the quad's corners, leaving + * a side on the texture's own edge unbounded, so sprites drawn from + * different frames through the same effect share one draw. Called by the + * quad batchers per quad; writes only when the texture's size changes, + * and is a no-op for any other body. * @param {number} sourceWidth - the texture's width, in pixels * @param {number} sourceHeight - the texture's height, in pixels * @ignore * @internal */ - _setFrameRect(u0, v0, u1, v1, sourceWidth, sourceHeight) { - const width = Math.max(sourceWidth, 1); - const height = Math.max(sourceHeight, 1); - const left = Math.min(u0, u1); - const top = Math.min(v0, v1); - const right = Math.max(u0, u1); - const bottom = Math.max(v0, v1); - // within a hundredth of a texel of the texture's edge: on it (a UV - // is a division, and rarely lands on 0 or 1 exactly) - const edge = 0.01; - this._writeFrameRect( - left * width <= edge ? UNBOUNDED_FRAME[0] : left, - top * height <= edge ? UNBOUNDED_FRAME[1] : top, - right * width >= width - edge ? UNBOUNDED_FRAME[2] : right, - bottom * height >= height - edge ? UNBOUNDED_FRAME[3] : bottom, - 0.5 / width, - 0.5 / height, + _setFrameTexel(sourceWidth, sourceHeight) { + this._writeFrameHalf( + 0.5 / Math.max(sourceWidth, 1), + 0.5 / Math.max(sourceHeight, 1), ); } /** - * Leave the samples of a body declaring `ME_frame` unbounded, for a pass - * over a whole texture (a render target blit): there, sampling is what - * it always was, clamped by the texture's own wrap mode. + * For a pass over a whole texture (a render target blit), which draws + * its quad with no frame: `frameTexel()` then samples as it always did, + * clamped by the texture's own wrap mode. * @ignore * @internal */ _clearFrameRect() { - this._writeFrameRect( - UNBOUNDED_FRAME[0], - UNBOUNDED_FRAME[1], - UNBOUNDED_FRAME[2], - UNBOUNDED_FRAME[3], - 0, - 0, - ); + this._writeFrameHalf(0, 0); } /** * @ignore * @internal */ - _writeFrameRect(left, top, right, bottom, halfX, halfY) { - const rect = this._frameRect; + _writeFrameHalf(halfX, halfY) { const half = this._frameHalf; - if (rect === undefined || this.destroyed === true) { + if (half === undefined || this.destroyed === true) { return; } - if ( - rect[0] === left && - rect[1] === top && - rect[2] === right && - rect[3] === bottom && - half[0] === halfX && - half[1] === halfY - ) { + if (half[0] === halfX && half[1] === halfY) { return; } - rect[0] = left; - rect[1] = top; - rect[2] = right; - rect[3] = bottom; half[0] = halfX; half[1] = halfY; // straight to the program, past the record a mesh replays (as the // `noise_uv` rect does) if (typeof this.wgslRealization !== "undefined") { - this.wgslRealization.setUniform("ME_frame", rect); this.wgslRealization.setUniform("ME_frame_half", half); } else if (typeof this._shader !== "undefined") { - this._shader.setUniform("ME_frame", rect); this._shader.setUniform("ME_frame_half", half); } } @@ -722,13 +698,33 @@ export default class ShaderEffect { // WGSL: write the engine ME struct's CPU mirror (size_obj @0, // size_img @2, offset @4 — the scaffold's MEBuiltins layout); // snapshot-uploaded alongside the uniform block at bind time - const me = this.wgslRealization.meMirror; - me[0] = Math.max(Math.abs(objectWidth), 1); - me[1] = Math.max(Math.abs(objectHeight), 1); - me[2] = Math.max(sourceWidth, 1); - me[3] = Math.max(sourceHeight, 1); - me[4] = u0 * me[2]; - me[5] = v0 * me[3]; + const realization = this.wgslRealization; + const me = realization.meMirror; + const objW = Math.max(Math.abs(objectWidth), 1); + const objH = Math.max(Math.abs(objectHeight), 1); + const imgW = Math.max(sourceWidth, 1); + const imgH = Math.max(sourceHeight, 1); + const offX = u0 * imgW; + const offY = v0 * imgH; + if ( + me[0] === objW && + me[1] === objH && + me[2] === imgW && + me[3] === imgH && + me[4] === offX && + me[5] === offY + ) { + return; + } + // the quads queued under the old rect draw first (the mirror is + // uploaded when they draw) + realization.onUniformChange?.(); + me[0] = objW; + me[1] = objH; + me[2] = imgW; + me[3] = imgH; + me[4] = offX; + me[5] = offY; return; } if (typeof this._shader === "undefined" || this._shader.suspended) { @@ -781,6 +777,28 @@ export default class ShaderEffect { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrapT); } + /** + * Delete a GL texture this effect owns, and forget it wherever a batcher + * still holds it as bound: a filter change re-binds every texture a + * batcher holds, and binding a deleted one is a GL error + * @param {WebGLTexture} texture - the texture to delete + * @ignore + * @internal + */ + _deleteTexture(texture) { + this._renderer.batchers?.forEach((batcher) => { + const bound = batcher.boundTextures; + if (bound !== undefined) { + for (let i = 0; i < bound.length; i++) { + if (bound[i] === texture) { + bound[i] = undefined; + } + } + } + }); + this._shader.gl.deleteTexture(texture); + } + /** * Bind an **extra** texture to a named `sampler2D` uniform in this shader, so * a custom effect can read a *second* texture — a noise map, mask, gradient, @@ -847,6 +865,8 @@ export default class ShaderEffect { if (!wgslLive && image instanceof Texture2d) { image = image.getTexture(); } + // the sprites queued with the previous texture draw first + this.wgslRealization.onUniformChange?.(); this._extraTextures.set(name, { image, repeat, @@ -880,12 +900,14 @@ export default class ShaderEffect { // the effect is disabled — or mid-context-loss, where entries set // during the window used to vanish permanently — survives intact. const existing = this._extraTextures.get(name); + // the sprites queued with the previous texture draw first + this._shader._drawQueued(); if (existing && existing.tex !== null) { // drop the old STATIC GL texture we own so the new image re-uploads // into the SAME reserved unit (kept below) on the next enabled draw. // A live entry never owns a GL texture (tex stays null — the // renderer/caller owns the handle), so this correctly skips it. - this._shader.gl.deleteTexture(existing.tex); + this._deleteTexture(existing.tex); } this._extraTextures.set(name, { image, @@ -1193,7 +1215,7 @@ export default class ShaderEffect { this._renderer.cache.releaseUnit(entry.unit); } if (entry.tex !== null) { - this._shader.gl.deleteTexture(entry.tex); + this._deleteTexture(entry.tex); } } this._extraTextures.clear(); diff --git a/packages/melonjs/src/video/effects/squish.js b/packages/melonjs/src/video/effects/squish.js index 396762f400..12e675a96a 100644 --- a/packages/melonjs/src/video/effects/squish.js +++ b/packages/melonjs/src/video/effects/squish.js @@ -1,3 +1,4 @@ +import { FRAME_GLSL, FRAME_WGSL, FRAME_WGSL_MEMBERS } from "./frame.js"; import ShaderEffect from "./shadereffect.js"; /** @@ -13,9 +14,10 @@ const wgslFragment = ` struct SquishUniforms { uSquishFrame : vec4f, uSquishXFactor : f32, - uSquishBulge : f32, + uSquishBulge : f32,${FRAME_WGSL_MEMBERS} }; @group(3) @binding(0) var fx : SquishUniforms; +${FRAME_WGSL} fn apply(color : vec4f, uv : vec2f) -> vec4f { // -1..1 across the quad: the sprite's frame, stretched by the vertex scale @@ -27,8 +29,10 @@ fn apply(color : vec4f, uv : vec2f) -> vec4f { // middle, none at the top and bottom p.x = p.x / max(1.0 + fx.uSquishBulge * sqrt(max(1.0 - p.y * p.y, 0.0)), 0.0001); let local = (p + 1.0) * 0.5; - // sampled before the test, since textureSample needs uniform control flow - let texel = textureSample(uTexture, uSampler, fx.uSquishFrame.xy + clamp(local, vec2f(0.0), vec2f(1.0)) * fx.uSquishFrame.zw) * vColor; + // sampled before the test, since textureSample needs uniform control + // flow, and kept inside the sprite's frame (frame.js): never filtered + // with the next frame of the atlas along its edge + let texel = frameTexel(fx.uSquishFrame.xy + clamp(local, vec2f(0.0), vec2f(1.0)) * fx.uSquishFrame.zw) * vColor; let inside = all(local >= vec2f(0.0)) && all(local <= vec2f(1.0)); return select(vec4f(0.0), texel, inside); } @@ -97,6 +101,7 @@ export default class SquishEffect extends ShaderEffect { uniform vec4 uSquishFrame; uniform float uSquishXFactor; uniform float uSquishBulge; + ${FRAME_GLSL} vec4 apply(vec4 color, vec2 uv) { // -1..1 across the quad: the sprite's frame, stretched by the vertex scale vec2 p = (uv - uSquishFrame.xy) / uSquishFrame.zw * 2.0 - 1.0; @@ -107,12 +112,18 @@ export default class SquishEffect extends ShaderEffect { // the middle, none at the top and bottom p.x /= max(1.0 + uSquishBulge * sqrt(max(1.0 - p.y * p.y, 0.0)), 0.0001); vec2 local = (p + 1.0) * 0.5; + // sampled before the test, while every pixel of the 2x2 block + // still runs: a sample after a branch some of them skipped + // has no derivatives to pick its mipmap level from, and reads + // a far too small one, a dark line along the sprite's edge + // under "linear-mipmap". And kept inside the sprite's frame + // (frame.js): never filtered with the next frame of the atlas + // along its edge + vec4 texel = frameTexel(uSquishFrame.xy + clamp(local, 0.0, 1.0) * uSquishFrame.zw) * vColor; // transparent past the sprite: the room around it, or the // indent of a pinch - if (local.x < 0.0 || local.x > 1.0 || local.y < 0.0 || local.y > 1.0) { - return vec4(0.0); - } - return texture2D(uSampler, uSquishFrame.xy + local * uSquishFrame.zw) * vColor; + float inside = step(0.0, local.x) * step(local.x, 1.0) * step(0.0, local.y) * step(local.y, 1.0); + return texel * inside; } `, wgsl: wgslFragment, diff --git a/packages/melonjs/src/video/effects/wgsl/scaffold.js b/packages/melonjs/src/video/effects/wgsl/scaffold.js index 14aa6790cf..2b74ba51a4 100644 --- a/packages/melonjs/src/video/effects/wgsl/scaffold.js +++ b/packages/melonjs/src/video/effects/wgsl/scaffold.js @@ -3,7 +3,7 @@ * `glsl_realization.js`'s source templating. The user body is embedded * VERBATIM (WGSL module-scope declarations are order-independent); the * scaffold contributes the vertex/fragment entry points over the frozen - * 28-byte quad layout, the frame-globals and material groups, and the + * 36-byte quad layout, the frame-globals and material groups, and the * builtin declarations the parser detected. * * Frozen conventions carried over from the core quad shader: @@ -15,6 +15,17 @@ * @internal */ +/** + * reads of the frame uniform by frame.js's FRAME_WGSL helpers (not of + * `fx.ME_frame_half`: no word boundary inside the longer name) + * @ignore + */ +const SPRITE_FRAME_READ = /\bfx\.ME_frame\b/; +/** + * @ignore + */ +const SPRITE_FRAME_READS = /\bfx\.ME_frame\b/g; + /** * builtin group-3 bindings are assigned ABOVE the highest user binding so * they can never collide: ME uniform first, then the capture texture and @@ -61,6 +72,14 @@ export function assignBuiltinBindings(parsed) { export function buildWGSLModule(body, parsed) { const builtins = parsed.builtins; const bindings = assignBuiltinBindings(parsed); + // a body keeping its samples inside its frame (frame.js's FRAME_WGSL): + // on a sprite the frame comes with the quad, so its reads of the + // uniform become reads of the frame the vertex stage hands over. The + // struct keeps the member, which a mesh hosting the body still reads + const spriteFrame = SPRITE_FRAME_READ.test(body); + if (spriteFrame) { + body = body.replace(SPRITE_FRAME_READS, "ME_frame_v"); + } const scaffold = [ "// ---- melonJS effect scaffold ----", @@ -81,6 +100,9 @@ export function buildWGSLModule(body, parsed) { // re-sampling bodies port literally scaffold.push("var vColor : vec4f;"); } + if (spriteFrame) { + scaffold.push("var ME_frame_v : vec4f;"); + } if (builtins.noiseUV) { scaffold.push( "var noise_uv : vec2f;", @@ -115,6 +137,7 @@ export function buildWGSLModule(body, parsed) { "\t@location(1) vColor : vec4f,", ...(builtins.screenUV ? ["\t@location(2) vScreenUV : vec2f,"] : []), ...(builtins.noiseUV ? ["\t@location(3) vNoiseUV : vec2f,"] : []), + ...(spriteFrame ? ["\t@location(4) vFrame : vec4f,"] : []), "};", "", "@vertex", @@ -123,6 +146,7 @@ export function buildWGSLModule(body, parsed) { "\t@location(1) aRegion : vec2f,", "\t@location(2) aColor : vec4f,", "\t@location(3) aTextureId : f32,", + ...(spriteFrame ? ["\t@location(4) aFrame : vec2f,"] : []), ") -> VSOut {", "\tvar out : VSOut;", "\tlet clip = uFrame.projection * vec4f(aVertex, 1.0);", @@ -142,6 +166,15 @@ export function buildWGSLModule(body, parsed) { "\tout.vNoiseUV = aRegion * (ME.size_img / ME.size_obj) - ME.offset / ME.size_obj;", ] : []), + ...(spriteFrame + ? [ + "\tout.vFrame = select(", + "\t\tvec4f(min(aRegion, aFrame), max(aRegion, aFrame)),", + "\t\tvec4f(-1.0e6, -1.0e6, 1.0e6, 1.0e6),", + "\t\taFrame.x < -5.0e5,", + "\t);", + ] + : []), "\treturn out;", "}", "", @@ -153,6 +186,19 @@ export function buildWGSLModule(body, parsed) { ...(builtins.screenUV ? ["\tscreen_uv = in.vScreenUV;"] : []), ...(builtins.noiseUV ? ["\tnoise_uv = in.vNoiseUV;"] : []), ...(builtins.vColor ? ["\tvColor = in.vColor;"] : []), + ...(spriteFrame + ? [ + // a side on the texture's own edge (within a hundredth of a + // texel) has no frame past it, and is left unbounded + "\tlet meEdge = fx.ME_frame_half * 0.02;", + "\tME_frame_v = vec4f(", + "\t\tselect(in.vFrame.x, -1.0e6, in.vFrame.x <= meEdge.x),", + "\t\tselect(in.vFrame.y, -1.0e6, in.vFrame.y <= meEdge.y),", + "\t\tselect(in.vFrame.z, 1.0e6, in.vFrame.z >= 1.0 - meEdge.x),", + "\t\tselect(in.vFrame.w, 1.0e6, in.vFrame.w >= 1.0 - meEdge.y),", + "\t);", + ] + : []), "\tlet texColor = textureSample(uTexture, uSampler, in.vRegion) * in.vColor;", "\treturn apply(texColor, in.vRegion);", "}", diff --git a/packages/melonjs/src/video/effects/wgsl_realization.js b/packages/melonjs/src/video/effects/wgsl_realization.js index e39d2fef1b..0045eae498 100644 --- a/packages/melonjs/src/video/effects/wgsl_realization.js +++ b/packages/melonjs/src/video/effects/wgsl_realization.js @@ -2,6 +2,32 @@ import { shiftGroup3, spliceEffect } from "./mesh_splice.js"; import { parseWGSLBody } from "./wgsl/parse.js"; import { buildWGSLModule } from "./wgsl/scaffold.js"; +/** + * whether a uniform's new value is the one it already has: numbers by + * value, vectors and matrices element by element (callers reuse arrays) + * @param {number|number[]|ArrayLike|undefined} previous - the value recorded + * @param {number|ArrayLike} value - the incoming value + * @returns {boolean} true when nothing changes + * @ignore + */ +function sameValue(previous, value) { + if (previous === undefined) { + return false; + } + if (typeof value === "number" || typeof previous === "number") { + return previous === value; + } + if (previous.length !== value.length) { + return false; + } + for (let i = 0; i < value.length; i++) { + if (previous[i] !== value[i]) { + return false; + } + } + return true; +} + /** * The WGSL realization of a ShaderEffect — the WebGPU counterpart of the * GLShader the GLSL branch compiles. @@ -97,6 +123,19 @@ export default class WGSLEffectRealization { * @type {object|null} */ this.gpu = null; + + /** + * set by the quad batcher drawing with this effect: called before a + * uniform (or the `noise_uv` rect) takes a new value, so the quads + * queued under the old one draw first. The mirror is uploaded when + * the queued quads draw, so without it they would all draw with the + * last value set. The same value again calls nothing, so sprites + * sharing the effect keep sharing a draw + * @type {(() => void)|null} + * @ignore + * @internal + */ + this.onUniformChange = null; } /** @@ -131,6 +170,12 @@ export default class WGSLEffectRealization { if (typeof value === "boolean") { value = value ? 1 : 0; } + if ( + this.onUniformChange !== null && + !sameValue(this.values.get(name), value) + ) { + this.onUniformChange(); + } const index = placement.offset >> 2; if (typeof value === "number") { if (placement.type === "i32") { diff --git a/packages/melonjs/src/video/gpu/frame.js b/packages/melonjs/src/video/gpu/frame.js new file mode 100644 index 0000000000..c991457823 --- /dev/null +++ b/packages/melonjs/src/video/gpu/frame.js @@ -0,0 +1,67 @@ +/** + * The frame a sprite quad draws, as the GPU backends carry it. + * + * A sprite cut from an atlas or a tileset draws one frame of a bigger + * texture. Every sprite vertex carries the UV of its quad's opposite corner, + * so with its own UV the shaders know that frame and can keep their samples + * inside it (see `webgl/shaders/frameclamp.js` and `quad.wgsl`). + * @ignore + */ + +/** + * The opposite-corner UV a sprite vertex carries when its quad has no frame + * to keep its samples inside: far below any UV a frame could have, so the + * sprite shaders read it as "unbounded". + * @ignore + * @internal + */ +export const FRAME_UNBOUNDED = -1e6; + +/** + * Whether a quad drawn from this texture has a frame to keep its samples + * inside: every texture but a repeating one (a pattern), which samples past + * its UVs on purpose so that the texture wraps. + * @param {{repeat?: string}} texture - the texture atlas drawn + * @returns {boolean} false for a repeating texture + * @ignore + * @internal + */ +export function isFramed(texture) { + const repeat = texture.repeat; + return repeat === undefined || repeat === "no-repeat"; +} + +/** + * The sprite shaders' frame clamp modes: off (every sprite samples as it + * always did), half a texel of the full-size art inside the frame (a tile + * layer drawn tile by tile, which must not blend with the next tile of its + * tileset at its edge), or half a texel of the mipmap level read (under + * `"linear-mipmap"`, where a small level's texels reach far past the frame). + * @ignore + * @internal + */ +export const FRAME_CLAMP = Object.freeze({ OFF: 0, TEXEL: 1, LEVELS: 2 }); + +/** + * How the sprite shaders keep their samples inside a quad's frame, for a + * renderer's current state: `FRAME_CLAMP.LEVELS` under `"linear-mipmap"`; + * at least `FRAME_CLAMP.TEXEL` while a tile layer draws its tiles one by + * one (`drawingTiles`), unless textures are sampled nearest, which never + * blends two texels; `FRAME_CLAMP.OFF` otherwise. + * @param {{settings: {textureFilter?: string}, drawingTiles?: boolean, getDefaultTextureFilter?: () => string}} renderer - the renderer + * @returns {number} the mode, as the shaders' `uFrameClamp` / `frameClamp` + * @ignore + * @internal + */ +export function frameClampMode(renderer) { + if (renderer.settings.textureFilter === "linear-mipmap") { + return FRAME_CLAMP.LEVELS; + } + if ( + renderer.drawingTiles === true && + renderer.getDefaultTextureFilter?.() !== "nearest" + ) { + return FRAME_CLAMP.TEXEL; + } + return FRAME_CLAMP.OFF; +} diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index dbfe8836ac..a96a2cb3c1 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -304,8 +304,14 @@ export default class Renderer { */ this._fog3d = null; - // default uvOffset - this.uvOffset = 0; + /** + * true while a tile layer draws its tiles one by one, between + * `beginTileLayer` and `endTileLayer` + * @type {boolean} + * @ignore + * @internal + */ + this.drawingTiles = false; /** * The normal-map texture associated with the next `drawImage` call, @@ -1396,32 +1402,64 @@ export default class Renderer { */ drawTileLayer(layer, rect) { if (layer.canvasRenderer) { - // clamp the source rect to the cached canvas bounds — the - // visible region can start inside the layer (camera scrolled - // in from the origin) and extend past the layer's right / - // bottom edge, so the source width must be reduced by - // `rect.pos.*` to avoid reading past the canvas - const width = Math.min(rect.width, layer.width - rect.pos.x); - const height = Math.min(rect.height, layer.height - rect.pos.y); + // the visible region, in the baked canvas, whose top left corner + // sits at (_preRenderX, _preRenderY): clamped to the canvas, as + // the region can start before it or run past it + const canvas = layer.canvasRenderer.getCanvas(); + const originX = layer._preRenderX ?? 0; + const originY = layer._preRenderY ?? 0; + const sx = Math.max(rect.pos.x - originX, 0); + const sy = Math.max(rect.pos.y - originY, 0); + const width = + Math.min(rect.pos.x + rect.width - originX, canvas.width) - sx; + const height = + Math.min(rect.pos.y + rect.height - originY, canvas.height) - sy; if (width <= 0 || height <= 0) { return; } this.drawImage( - layer.canvasRenderer.getCanvas(), - rect.pos.x, - rect.pos.y, + canvas, + sx, + sy, width, height, - rect.pos.x, - rect.pos.y, + sx + originX, + sy + originY, width, height, ); return; } - layer.getRenderer().drawTileLayer(this, layer, rect); + // a tile keeps to its own tile while the layer draws (see + // `beginTileLayer`): no seam between two of them + this.beginTileLayer(); + try { + layer.getRenderer().drawTileLayer(this, layer, rect); + } finally { + this.endTileLayer(); + } } + /** + * Called before a tile layer draws its tiles one by one, and paired with + * {@link Renderer#endTileLayer} after. Between the two, the GPU + * renderers keep every sample half a texel inside its tile, so that + * filtering never blends the next tile of the tileset, or the spacing + * around it, into a tile's edge: a seam between two tiles. The base, and + * so the Canvas renderer, does nothing. + * @ignore + * @internal + */ + beginTileLayer() {} + + /** + * Called after a tile layer has drawn its tiles (see + * {@link Renderer#beginTileLayer}). The base does nothing. + * @ignore + * @internal + */ + endTileLayer() {} + /** * Set the current fill & stroke style color. * By default, or upon reset, the value is set to #000000. @@ -2046,8 +2084,9 @@ export default class Renderer { /** * Resolve the default texture filter **mode** for this renderer, decoupled * from {@link Renderer#setAntiAlias} (which controls polygon-edge MSAA on GPU - * backends). Honors the `textureFilter` setting (`"nearest"` / `"linear"`), - * falling back to the `antiAlias` setting when it's `"auto"` (the default). + * backends). Honors the `textureFilter` setting (`"nearest"` / `"linear"`, + * `"linear-mipmap"` resolving to `"linear"`), falling back to the + * `antiAlias` setting when it's `"auto"` (the default). * * Backend-neutral on purpose: it returns the mode as a string so each GPU * backend maps it to its own enum (WebGL `gl.LINEAR` / `gl.NEAREST`, a future @@ -2060,16 +2099,49 @@ export default class Renderer { if (mode === "linear" || mode === "nearest") { return mode; } + // linear, plus mipmaps where `wantsMipmaps` allows them + if (mode === "linear-mipmap") { + return "linear"; + } // "auto" (or unset) → follow the antiAlias setting return this.settings.antiAlias ? "linear" : "nearest"; } + /** + * Whether a texture drawn in 2D samples its mipmaps: the + * `"linear-mipmap"` filter, over a source the backend keeps a full chain + * for. Not a video, which would have to rebuild it every frame, nor a + * texture the engine renders into (a `TextureResource`), whose smaller + * levels are never filled. The backend also requires the texture's own + * filter to be linear. Checks the filter first, so that with any other + * the texture is never looked at. + * @param {object} texture - the texture drawn (a `TextureAtlas`) + * @returns {boolean} true to sample the mipmaps + * @ignore + * @internal + */ + wantsMipmaps(texture) { + if (this.settings.textureFilter !== "linear-mipmap") { + return false; + } + const source = texture.getTexture(); + return ( + typeof source.videoWidth === "undefined" && + typeof source.upload !== "function" + ); + } + /** * Set the default texture magnification/minification filter at runtime, * decoupled from {@link Renderer#setAntiAlias}. The base implementation just * records the setting (the Canvas renderer has no per-texture filtering); * GPU backends override this to re-apply the filter to live textures. - * @param {"auto"|"nearest"|"linear"} [mode="auto"] - `"auto"` follows `antiAlias` + * @param {"auto"|"nearest"|"linear"|"linear-mipmap"} [mode="auto"] - `"auto"` follows `antiAlias` + * @example + * // smooth, and from the mipmaps wherever something is drawn small + * app.renderer.setTextureFilter("linear-mipmap"); + * // back to the antiAlias setting + * app.renderer.setTextureFilter("auto"); */ setTextureFilter(mode = "auto") { this.settings.textureFilter = mode; diff --git a/packages/melonjs/src/video/webgl/batchers/batcher.js b/packages/melonjs/src/video/webgl/batchers/batcher.js index da465ada2d..edd87aeb2e 100644 --- a/packages/melonjs/src/video/webgl/batchers/batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/batcher.js @@ -437,6 +437,11 @@ export class WebGLBatcher extends Batcher { this.renderer.currentProgram !== shader.program ) { this.flush(); + // this batcher draws with it now: a change to one of its + // uniforms draws the quads queued with it first (see + // GLShader#setUniform). A ShaderEffect holds its program in + // `_shader` + (shader._shader ?? shader)._host = this; shader.bind(); shader.setUniform(this.projectionUniform, this.renderer.projectionMatrix); diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js index 2dbb24e887..ce4df71498 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -1,4 +1,5 @@ import { off, on, TEXTURE2D_DESTROYED } from "../../../system/event.ts"; +import { FRAME_UNBOUNDED, isFramed } from "../../gpu/frame.js"; import { scaleQuad, transformQuadCorners } from "../../gpu/quadcorners.ts"; import UniformBlock from "../buffer/uniformblock.js"; import { MAX_LIGHTS } from "../lighting/constants.ts"; @@ -59,7 +60,7 @@ export default class LitQuadBatcher extends QuadBatcher { attributes: [ { // vec3: (x, y, z). z carries `renderable.depth` for - // perspective projection (Camera3d). Stride = 36 bytes. + // perspective projection (Camera3d). Stride = 44 bytes. name: "aVertex", format: "float32x3", offset: 0 * Float32Array.BYTES_PER_ELEMENT, @@ -79,10 +80,19 @@ export default class LitQuadBatcher extends QuadBatcher { format: "float32", offset: 6 * Float32Array.BYTES_PER_ELEMENT, }, + { + // the UV of the quad's opposite corner: the frame the + // quad draws (see shaders/frameclamp.js). Fifth, as in + // every sprite layout, so an effect drawn on a lit + // sprite reads it where it reads it on any other + name: "aFrame", + format: "float32x2", + offset: 7 * Float32Array.BYTES_PER_ELEMENT, + }, { name: "aNormalTextureId", format: "float32", - offset: 7 * Float32Array.BYTES_PER_ELEMENT, + offset: 9 * Float32Array.BYTES_PER_ELEMENT, }, { // Specular exponent, per QUAD rather than per normal-map @@ -95,7 +105,7 @@ export default class LitQuadBatcher extends QuadBatcher { // lit path only, buys both problems away. name: "aShininess", format: "float32", - offset: 8 * Float32Array.BYTES_PER_ELEMENT, + offset: 10 * Float32Array.BYTES_PER_ELEMENT, }, ], shader: { @@ -108,6 +118,7 @@ export default class LitQuadBatcher extends QuadBatcher { // shader/attribute layout, just iterate `this.maxBatchTextures`. this.bindColorSamplers(); this.createIndexBuffer(); + this.syncFrameClamp(); this.useMultiTexture = true; @@ -603,14 +614,7 @@ export default class LitQuadBatcher extends QuadBatcher { Math.min(u0, u1), Math.min(v0, v1), ); - this.currentShader._setFrameRect?.( - u0, - v0, - u1, - v1, - sourceWidth, - sourceHeight, - ); + this.currentShader._setFrameTexel?.(sourceWidth, sourceHeight); } // direct atlas sampling: uv.y grows downward — see // QuadBatcher.addQuad @@ -667,47 +671,18 @@ export default class LitQuadBatcher extends QuadBatcher { ); const textureId = this.useMultiTexture ? unit : 0; - vertexData.push( - vec0.x, - vec0.y, - vec0.z, + vertexData.pushSpriteQuad( + vec0, + vec1, + vec2, + vec3, u0, v0, - tint, - textureId, - normalTextureId, - shininess, - ); - vertexData.push( - vec1.x, - vec1.y, - vec1.z, - u1, - v0, - tint, - textureId, - normalTextureId, - shininess, - ); - vertexData.push( - vec2.x, - vec2.y, - vec2.z, - u0, - v1, - tint, - textureId, - normalTextureId, - shininess, - ); - vertexData.push( - vec3.x, - vec3.y, - vec3.z, u1, v1, tint, textureId, + isFramed(texture), normalTextureId, shininess, ); @@ -759,13 +734,16 @@ export default class LitQuadBatcher extends QuadBatcher { 0, ); - // blits are always rendered at z = 0 (screen-space, ortho) + // blits are always rendered at z = 0 (screen-space, ortho), carry no + // normal map (-1) and no material (shininess 0), and draw the whole + // target: no frame const tint = 0xffffffff; - // trailing 0 is `aShininess`: a blit carries no material - this.vertexData.push(vec0.x, vec0.y, 0, 0, 1, tint, 0, -1, 0); - this.vertexData.push(vec1.x, vec1.y, 0, 1, 1, tint, 0, -1, 0); - this.vertexData.push(vec2.x, vec2.y, 0, 0, 0, tint, 0, -1, 0); - this.vertexData.push(vec3.x, vec3.y, 0, 1, 0, tint, 0, -1, 0); + const open = FRAME_UNBOUNDED; + const vertexData = this.vertexData; + vertexData.pushSprite(vec0.x, vec0.y, 0, 0, 1, tint, 0, open, open, -1, 0); + vertexData.pushSprite(vec1.x, vec1.y, 0, 1, 1, tint, 0, open, open, -1, 0); + vertexData.pushSprite(vec2.x, vec2.y, 0, 0, 0, tint, 0, open, open, -1, 0); + vertexData.pushSprite(vec3.x, vec3.y, 0, 1, 0, tint, 0, open, open, -1, 0); this.flush(); diff --git a/packages/melonjs/src/video/webgl/batchers/material_batcher.js b/packages/melonjs/src/video/webgl/batchers/material_batcher.js index bce8c53ca0..d4ca583d69 100644 --- a/packages/melonjs/src/video/webgl/batchers/material_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/material_batcher.js @@ -630,10 +630,16 @@ export class MaterialBatcher extends WebGLBatcher { // texture's own parameters, so one upload can serve a source drawn at // several repeat modes. `createTexture2D` still sets the texture // parameters too, which keeps any path that binds no sampler working - // exactly as before. + // exactly as before. Under `"linear-mipmap"` it samples the chain + // `createTexture2D` generated (a mip sampler is only built over a + // linear filter, so a `nearest` texture keeps to its full size). this.renderer.samplerCache.bind( unit, - this.renderer.samplerCache.get(filter, wrap, false), + this.renderer.samplerCache.get( + filter, + wrap, + this.renderer.wantsMipmaps(texture), + ), ); return flush ? this.currentTextureUnit : unit; diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js index 708c602f4e..37665d48dd 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -1,3 +1,4 @@ +import { FRAME_UNBOUNDED, frameClampMode, isFramed } from "../../gpu/frame.js"; import { scaleQuad, transformQuadCorners } from "../../gpu/quadcorners.ts"; import IndexBuffer from "../buffer/index.js"; import { buildMultiTextureFragment } from "./../shaders/multitexture.js"; @@ -41,7 +42,7 @@ export default class QuadBatcher extends MaterialBatcher { attributes: [ { // vec3: (x, y, z). z carries `renderable.depth` for - // perspective projection (Camera3d). Stride = 28 bytes. + // perspective projection (Camera3d). Stride = 36 bytes. name: "aVertex", format: "float32x3", offset: 0 * Float32Array.BYTES_PER_ELEMENT, @@ -61,6 +62,16 @@ export default class QuadBatcher extends MaterialBatcher { format: "float32", offset: 6 * Float32Array.BYTES_PER_ELEMENT, }, + { + // the UV of the quad's opposite corner, which with the + // vertex's own makes the frame the quad draws (see + // shaders/frameclamp.js). After the attributes a custom + // shader declares, so those keep their locations; the + // lit layout keeps it here too, its extras after it + name: "aFrame", + format: "float32x2", + offset: 7 * Float32Array.BYTES_PER_ELEMENT, + }, ], shader: { vertex: quadMultiVertex, @@ -69,6 +80,7 @@ export default class QuadBatcher extends MaterialBatcher { }); this.bindColorSamplers(); + this.syncFrameClamp(); /** * whether multi-texture batching is currently active @@ -121,6 +133,41 @@ export default class QuadBatcher extends MaterialBatcher { } } + /** + * Point the default shader's frame clamp at the renderer's texture + * filter: on under `"linear-mipmap"`, where a sprite drawn small reads a + * mipmap level wide enough to reach into the next frame of its atlas, + * off otherwise (see `shaders/frameclamp.js`). Called at init and when + * the filter changes; the quads already queued draw under the mode they + * were queued with. + * @ignore + * @internal + */ + syncFrameClamp() { + const shader = this.defaultShader; + const mode = frameClampMode(this.renderer); + // keyed to the shader too: a rebuilt default shader starts unset + if (this._frameClampShader === shader && this._frameClamp === mode) { + return; + } + this.flush(); + // binds the default program: keep the renderer's record of the bound + // program true, so the next draw re-binds whichever shader it needs + shader.setUniform("uFrameClamp", mode); + this.renderer.currentProgram = shader.program; + /** + * the frame clamp mode last written to `_frameClampShader` + * @ignore + * @internal + */ + this._frameClamp = mode; + /** + * @ignore + * @internal + */ + this._frameClampShader = shader; + } + /** * Select the shader to use for compositing. * Multi-texture batching is automatically enabled when the default @@ -248,12 +295,14 @@ export default class QuadBatcher extends MaterialBatcher { ); // blits stay at z = 0 (screen-space, ortho); pre-PR behavior - // preserved unchanged + // preserved unchanged. A blit draws the whole target: no frame const tint = 0xffffffff; - this.vertexData.push(vec0.x, vec0.y, 0, 0, 1, tint, 0); - this.vertexData.push(vec1.x, vec1.y, 0, 1, 1, tint, 0); - this.vertexData.push(vec2.x, vec2.y, 0, 0, 0, tint, 0); - this.vertexData.push(vec3.x, vec3.y, 0, 1, 0, tint, 0); + const open = FRAME_UNBOUNDED; + const vertexData = this.vertexData; + vertexData.pushSprite(vec0.x, vec0.y, 0, 0, 1, tint, 0, open, open); + vertexData.pushSprite(vec1.x, vec1.y, 0, 1, 1, tint, 0, open, open); + vertexData.pushSprite(vec2.x, vec2.y, 0, 0, 0, tint, 0, open, open); + vertexData.pushSprite(vec3.x, vec3.y, 0, 1, 0, tint, 0, open, open); this.flush(); @@ -327,16 +376,10 @@ export default class QuadBatcher extends MaterialBatcher { Math.min(u0, u1), Math.min(v0, v1), ); - // and keep its samples inside that frame: on an atlas, the - // texture goes on into the neighbouring frames - this.currentShader._setFrameRect?.( - u0, - v0, - u1, - v1, - sourceWidth, - sourceHeight, - ); + // and keep its samples inside that frame (on an atlas, the + // texture goes on into the neighbouring frames): the frame + // comes with the quad, the half texel with the texture + this.currentShader._setFrameTexel?.(sourceWidth, sourceHeight); } // direct atlas sampling: uv.y grows downward (directional bodies // compensate on the pooled path, where captures are bottom-up) @@ -369,11 +412,21 @@ export default class QuadBatcher extends MaterialBatcher { // 4 vertices per quad; the index buffer provides the 6 indices. // textureId is the unit index for multi-texture, or 0 for - // single-texture fallback. + // single-texture fallback. A repeating texture (a pattern) samples + // past its UVs on purpose, so it has no frame to keep to const textureId = this.useMultiTexture ? unit : 0; - vertexData.push(vec0.x, vec0.y, vec0.z, u0, v0, tint, textureId); - vertexData.push(vec1.x, vec1.y, vec1.z, u1, v0, tint, textureId); - vertexData.push(vec2.x, vec2.y, vec2.z, u0, v1, tint, textureId); - vertexData.push(vec3.x, vec3.y, vec3.z, u1, v1, tint, textureId); + vertexData.pushSpriteQuad( + vec0, + vec1, + vec2, + vec3, + u0, + v0, + u1, + v1, + tint, + textureId, + isFramed(texture), + ); } } diff --git a/packages/melonjs/src/video/webgl/glshader.js b/packages/melonjs/src/video/webgl/glshader.js index 4b829a04d6..97c70a933f 100644 --- a/packages/melonjs/src/video/webgl/glshader.js +++ b/packages/melonjs/src/video/webgl/glshader.js @@ -8,7 +8,11 @@ import { extractAttributes } from "./utils/attributes.js"; import { getMaxShaderPrecision, setPrecision } from "./utils/precision.js"; import { compileProgram } from "./utils/program.js"; import { minify } from "./utils/string.js"; -import { captureValue, extractUniforms } from "./utils/uniforms.js"; +import { + captureValue, + extractUniforms, + valuesMatch, +} from "./utils/uniforms.js"; /** * a complete custom shader program. Historically a WebGL-only GLSL @@ -210,6 +214,15 @@ export default class GLShader { */ this._uniformCache = Object.create(null); + /** + * the batcher that last bound this shader, whose queued quads a + * uniform change draws first (see {@link GLShader#setUniform}) + * @type {object|undefined} + * @ignore + * @internal + */ + this._host = undefined; + if (this.isWebGL) { // defer compile if constructed mid-suspended-window; replay handles it if (gl.isContextLost()) { @@ -365,6 +378,24 @@ export default class GLShader { } } + /** + * Draw the quads the batcher hosting this shader queued with it, before + * something they are drawn with changes (a uniform, a texture): they + * draw when the batch is flushed, and would otherwise take the new one + * @ignore + * @internal + */ + _drawQueued() { + const host = this._host; + if ( + host !== undefined && + host.vertexData.vertexCount > 0 && + (host.currentShader === this || host.currentShader?._shader === this) + ) { + host.flush(); + } + } + /** * Set the uniform to the given value * @param {string} name - the uniform name @@ -381,25 +412,34 @@ export default class GLShader { if (this.destroyed) { return; } + const incoming = + typeof value === "object" && + value !== null && + typeof value.toArray === "function" + ? value.toArray() + : value; + // quads queued with this shader and not drawn yet were queued under + // the value this one replaces: the batcher holding them draws them + // first. The same value again changes nothing, so sprites sharing a + // shader keep sharing a draw + if ( + this._host?.vertexData.vertexCount > 0 && + !valuesMatch(this._uniformCache[name], incoming) + ) { + this._drawQueued(); + } // cache via captureValue (slot reuse, mutation-detached); pass // caller's value to GL (uniform setters copy synchronously) let cached; - let glValue; - if (typeof value === "object" && value !== null) { - if (typeof value.toArray === "function") { - const arr = value.toArray(); - cached = captureValue(this._uniformCache[name], arr); - glValue = arr; - } else if (Array.isArray(value) || ArrayBuffer.isView(value)) { - cached = captureValue(this._uniformCache[name], value); - glValue = value; + const glValue = incoming; + if (typeof incoming === "object" && incoming !== null) { + if (Array.isArray(incoming) || ArrayBuffer.isView(incoming)) { + cached = captureValue(this._uniformCache[name], incoming); } else { - cached = value; - glValue = value; + cached = incoming; } } else { - cached = value; - glValue = value; + cached = incoming; } this._uniformCache[name] = cached; diff --git a/packages/melonjs/src/video/webgl/renderers/tmxlayer/orthogonal.js b/packages/melonjs/src/video/webgl/renderers/tmxlayer/orthogonal.js index c09702efcc..6901f4c035 100644 --- a/packages/melonjs/src/video/webgl/renderers/tmxlayer/orthogonal.js +++ b/packages/melonjs/src/video/webgl/renderers/tmxlayer/orthogonal.js @@ -333,7 +333,9 @@ export default class OrthogonalTMXLayerGPURenderer { this._setV2("uVisibleStart", visStartX, visStartY); this._setV2("uVisibleSize", visSizeX, visSizeY); this._setV2("uCellSize", tileWidth, tileHeight); - this.shader.setUniform("uOpacity", layer.getOpacity()); + // the layer's opacity, and the fade of everything it is drawn in: + // set on the renderer by the layer's preDraw + this.shader.setUniform("uOpacity", renderer.getGlobalAlpha()); this.shader.setUniform( "uTint", layer.tint ? layer.tint.toArray() : DEFAULT_TINT, @@ -404,6 +406,19 @@ export default class OrthogonalTMXLayerGPURenderer { Math.max(0, Math.ceil(tsH / tileHeight) - 1), ); this._setV2("uGidRange", tileset.firstgid, tileset.lastgid); + // the tileset samples its mipmaps when the batcher binds it a + // mip sampler: the "linear-mipmap" filter, on a linear texture + const filter = + typeof tileset.texture.filter !== "undefined" + ? tileset.texture.filter + : batcher.renderer._glTextureFilter(); + this.shader.setUniform( + "uMipmaps", + (filter === this.gl.LINEAR || filter === "linear") && + batcher.renderer.wantsMipmaps(tileset.texture) + ? 1 + : 0, + ); // emit one screen-quad. The batcher will bind the tileset's // TextureAtlas to uSampler (single-texture fallback path is diff --git a/packages/melonjs/src/video/webgl/shaders/frameclamp.js b/packages/melonjs/src/video/webgl/shaders/frameclamp.js new file mode 100644 index 0000000000..7398645328 --- /dev/null +++ b/packages/melonjs/src/video/webgl/shaders/frameclamp.js @@ -0,0 +1,65 @@ +/** + * Keeping a sprite's samples inside its own frame, in the default sprite + * shaders (GLSL ES 3.00). The WGSL twins live in `quad.wgsl` and + * `quad-lit.wgsl`. + * + * A sprite cut from an atlas or a tileset draws one frame of a bigger + * texture, and the texture goes on past the frame into the neighbouring + * ones. Sampling reaches past the point it reads by half a texel of the + * level it reads, so a sample near the frame's edge picks up a little of + * the next frame. At full size that is half a texel, and mostly invisible; + * read from a small mipmap level, a texel spans many of the art's pixels, + * and the next frame's edge shows as a faint line along the sprite's. + * + * Every sprite vertex carries the UV of its quad's opposite corner + * (`aFrame`), so with its own UV the vertex knows the quad's frame. The + * fragment keeps its sample half a texel of the deepest level it reads + * inside that frame, as the orthogonal tilemap shader keeps a tile's. A + * quad with no frame of its own (a repeating pattern, a render target + * blit) carries {@link FRAME_UNBOUNDED} instead and is left alone. + * + * `uFrameClamp` says how (see `gpu/frame.js`): 0 off, where every sprite + * samples as it always did; 1 half a texel of the full-size art, for a tile + * layer drawn tile by tile, whose tiles must not blend with the next tile + * of their tileset; 2 half a texel of the level read, under the + * `"linear-mipmap"` texture filter. + * @ignore + */ + +/** + * The fragment-stage half: `frameSample(s, uv, gx, gy)` samples `s` at `uv`, + * kept inside the quad's frame when `uFrameClamp` is on. `gx` and `gy` are + * the UV's screen derivatives, taken where control flow is uniform (the + * texture slot ladder is not), so the clamped sample reads the level the + * unclamped one would. + * + * The vertex-stage half lives in `quad-multi.vert` and `quad-multi-lit.vert` + * (static sources): `vFrame`, the quad's frame from the vertex's own UV and + * `aFrame`, is the same for all four corners, so plain interpolation + * carries it unchanged. `aFrame` is the fifth attribute of every sprite + * layout: after the attributes a custom shader declares, so those keep + * their locations, and before anything a layout adds (the lit extras), so + * an effect's program reads it on any sprite layout. + * @ignore + * @internal + */ +export const FRAME_FRAGMENT_GLSL = ` +uniform float uFrameClamp; +in vec4 vFrame; +vec4 frameSample(sampler2D s, vec2 uv, vec2 gx, vec2 gy) { + if (uFrameClamp < 0.5) { + return texture(s, uv); + } + vec2 size = vec2(textureSize(s, 0)); + // the deepest level read: the next one up once past a whole level, by + // more than a rounding error (drawn at its size, the derivatives are 1 + // give or take a few ulps, and that is level 0). Without mipmaps, the + // full-size art is the only level + float lod = uFrameClamp > 1.5 + ? ceil(log2(max(max(length(gx * size), length(gy * size)), 1.0)) - 0.01) + : 0.0; + // half a texel of it, never past the frame's centre + vec2 inset = min(vec2(0.5 * exp2(lod)) / size, (vFrame.zw - vFrame.xy) * 0.5); + return textureGrad(s, clamp(uv, vFrame.xy + inset, vFrame.zw - inset), gx, gy); +} +`; diff --git a/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js b/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js index 81a9a4bf24..e1cf70a20c 100644 --- a/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js +++ b/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js @@ -1,10 +1,14 @@ import { MAX_LIGHTS } from "../lighting/constants.ts"; +import { FRAME_FRAGMENT_GLSL } from "./frameclamp.js"; /** * Build the GLSL `if/else` chain that picks among N samplers based on * an interpolated float texture-id. Dynamic indexing of a `sampler2D` * array is forbidden in GLSL ES 3.00 exactly as it was in 1.00, so this - * stays an if-ladder using the usual `< i + 0.5` threshold pattern. + * stays an if-ladder using the usual `< i + 0.5` threshold pattern. Each + * sample stays inside the quad's frame under the `"linear-mipmap"` filter + * (see `frameclamp.js`); a normal map is UV-paired with its color atlas, + * so the two keep to the same frame. * @ignore * @internal */ @@ -17,12 +21,21 @@ function buildSamplerSelect(varName, samplerPrefix, count, target) { lines.push(" } else if (" + varName + " < " + (i + 0.5) + ") {"); } lines.push( - " " + target + " = texture(" + samplerPrefix + i + ", vRegion);", + " " + + target + + " = frameSample(" + + samplerPrefix + + i + + ", vRegion, gx, gy);", ); } lines.push(" } else {"); lines.push( - " " + target + " = texture(" + samplerPrefix + "0, vRegion);", + " " + + target + + " = frameSample(" + + samplerPrefix + + "0, vRegion, gx, gy);", ); lines.push(" }"); return lines; @@ -97,8 +110,11 @@ export function buildLitMultiTextureFragment(maxTextures) { lines.push("in float vShininess;"); lines.push("in vec2 vWorldPos;"); lines.push("out vec4 fragColor;"); - lines.push(""); + lines.push(FRAME_FRAGMENT_GLSL); lines.push("void main(void) {"); + // the UV's derivatives, while control flow is still uniform + lines.push(" vec2 gx = dFdx(vRegion);"); + lines.push(" vec2 gy = dFdy(vRegion);"); lines.push(" vec4 color;"); lines.push(...buildSamplerSelect("vTextureId", "uSampler", count, "color")); diff --git a/packages/melonjs/src/video/webgl/shaders/multitexture.js b/packages/melonjs/src/video/webgl/shaders/multitexture.js index 30c6738c8b..70dc317fba 100644 --- a/packages/melonjs/src/video/webgl/shaders/multitexture.js +++ b/packages/melonjs/src/video/webgl/shaders/multitexture.js @@ -1,7 +1,11 @@ +import { FRAME_FRAGMENT_GLSL } from "./frameclamp.js"; + /** - * Generates a multi-texture fragment shader source string. + * Generates a multi-texture fragment shader source string (GLSL ES 3.00). * Declares individual sampler uniforms (uSampler0..uSamplerN) and uses * an if/else chain with 0.5-offset thresholds to select the correct texture unit. + * Each sample stays inside the quad's frame under the `"linear-mipmap"` + * filter (see `frameclamp.js`). * @param {number} maxTextures - the number of texture units to support * @returns {string} GLSL fragment shader source * @ignore @@ -9,18 +13,24 @@ */ export function buildMultiTextureFragment(maxTextures) { const count = Math.max(maxTextures, 1); - const lines = []; + // `precision` is inserted after the version directive by + // utils/precision.js, so it is deliberately not written here + const lines = ["#version 300 es"]; // declare sampler uniforms for (let i = 0; i < count; i++) { lines.push("uniform sampler2D uSampler" + i + ";"); } - lines.push("varying vec4 vColor;"); - lines.push("varying vec2 vRegion;"); - lines.push("varying float vTextureId;"); - lines.push(""); + lines.push("in vec4 vColor;"); + lines.push("in vec2 vRegion;"); + lines.push("in float vTextureId;"); + lines.push("out vec4 fragColor;"); + lines.push(FRAME_FRAGMENT_GLSL); lines.push("void main(void) {"); + // the UV's derivatives, while control flow is still uniform + lines.push(" vec2 gx = dFdx(vRegion);"); + lines.push(" vec2 gy = dFdy(vRegion);"); lines.push(" vec4 color;"); // generate if/else chain using < N.5 thresholds @@ -30,14 +40,16 @@ export function buildMultiTextureFragment(maxTextures) { } else { lines.push(" } else if (vTextureId < " + (i + 0.5) + ") {"); } - lines.push(" color = texture2D(uSampler" + i + ", vRegion);"); + lines.push( + " color = frameSample(uSampler" + i + ", vRegion, gx, gy);", + ); } // fallback to first sampler if vTextureId is out of range lines.push(" } else {"); - lines.push(" color = texture2D(uSampler0, vRegion);"); + lines.push(" color = frameSample(uSampler0, vRegion, gx, gy);"); lines.push(" }"); - lines.push(" gl_FragColor = color * vColor;"); + lines.push(" fragColor = color * vColor;"); lines.push("}"); return lines.join("\n"); diff --git a/packages/melonjs/src/video/webgl/shaders/orthogonal-tmxlayer.frag b/packages/melonjs/src/video/webgl/shaders/orthogonal-tmxlayer.frag index aa4f23dae5..e81ed0f6fb 100644 --- a/packages/melonjs/src/video/webgl/shaders/orthogonal-tmxlayer.frag +++ b/packages/melonjs/src/video/webgl/shaders/orthogonal-tmxlayer.frag @@ -70,6 +70,7 @@ uniform vec2 uVisibleSize; uniform int uAnimSize; // number of entries in uAnimLookup, 0 if disabled uniform float uOpacity; uniform vec4 uTint; +uniform float uMipmaps; // 1 under the "linear-mipmap" filter, else 0 out vec4 fragColor; @@ -80,7 +81,13 @@ const int MAX_OVERFLOW = 4; // contains a visible, in-range tile whose pixel covers this fragment. // Identical logic for both fast and slow paths — GLSL inlines this // trivially so there's no function-call overhead at runtime. -bool tryRenderCell(int cx, int cy, vec2 worldPx, out vec4 outColor) { +// +// `dWorldX` / `dWorldY` are the world position's screen derivatives, taken +// in `main` where control flow is still uniform: the atlas coordinate jumps +// from one tile to the next, so its own derivatives would pick a tiny mip +// level along every tile edge. A texel of the atlas is a world pixel, so +// the world's derivatives are the atlas's. +bool tryRenderCell(int cx, int cy, vec2 worldPx, vec2 dWorldX, vec2 dWorldY, out vec4 outColor) { int mapW = int(uMapSize.x); int mapH = int(uMapSize.y); if (cx < 0 || cx >= mapW || cy < 0 || cy >= mapH) { @@ -135,9 +142,25 @@ bool tryRenderCell(int cx, int cy, vec2 worldPx, out vec4 outColor) { vec2 tileOriginPx = uTilesetMargin.xy + vec2(col, row) * (uTileSize + uTilesetMargin.zw); vec2 texelPx = tileOriginPx + inTile * uTileSize; + // keep the sample half a texel of the level it reads inside the tile, + // so filtering never blends in the next tile of the tileset, or the + // spacing around it, along the tile's edge: a seam between two tiles + // (never past the tile's centre). Without mipmaps that level is the + // full-size art; under them, the deepest level read is the next one up + // once the level is past a whole number, by more than a rounding error: + // drawn at its size, the derivatives are 1 give or take a few ulps, and + // level 0 it is. Sampled nearest, the sample stays in the same texel + float lod = ceil(log2(max(max(length(dWorldX), length(dWorldY)), 1.0)) - 0.01); + vec2 inset = min(vec2(0.5 * exp2(lod * uMipmaps)), uTileSize * 0.5); + texelPx = clamp(texelPx, tileOriginPx + inset, tileOriginPx + uTileSize - inset); vec2 texelUV = texelPx * uInvTilesetSize; - vec4 sampled = texture(uSampler, texelUV); + vec4 sampled = textureGrad( + uSampler, + texelUV, + dWorldX * uInvTilesetSize, + dWorldY * uInvTilesetSize + ); if (sampled.a <= 0.0) { return false; } @@ -145,10 +168,20 @@ bool tryRenderCell(int cx, int cy, vec2 worldPx, out vec4 outColor) { return true; } +// the layer's opacity and tint applied to a texel. The texel is +// premultiplied, as the blending expects: the opacity scales its color as +// well as its alpha. The tint colors it; its alpha is not an opacity, as on +// a sprite +vec4 layerColor(vec4 texel) { + return texel * uOpacity * vec4(uTint.rgb, 1.0); +} + void main(void) { vec2 tileCoord = uVisibleStart + vRegion * uVisibleSize; vec2 geomCell = floor(tileCoord); vec2 worldPx = tileCoord * uCellSize; + vec2 dWorldX = dFdx(worldPx); + vec2 dWorldY = dFdy(worldPx); int gx = int(geomCell.x); int gy = int(geomCell.y); @@ -160,11 +193,10 @@ void main(void) { // Fast path: tiles fit the cell exactly (the common case) — only the // geometric cell can contain this fragment's tile. if (overflowX == 0 && overflowY == 0) { - if (!tryRenderCell(gx, gy, worldPx, result)) { + if (!tryRenderCell(gx, gy, worldPx, dWorldX, dWorldY, result)) { discard; } - result.a *= uOpacity; - fragColor = result * uTint; + fragColor = layerColor(result); return; } @@ -177,7 +209,7 @@ void main(void) { if (dy > overflowY) continue; for (int dx = 0; dx <= MAX_OVERFLOW; dx++) { if (dx > overflowX) break; - if (tryRenderCell(gx - dx, gy + dy, worldPx, result)) { + if (tryRenderCell(gx - dx, gy + dy, worldPx, dWorldX, dWorldY, result)) { found = true; break; } @@ -185,6 +217,5 @@ void main(void) { if (found) break; } if (!found) discard; - result.a *= uOpacity; - fragColor = result * uTint; + fragColor = layerColor(result); } diff --git a/packages/melonjs/src/video/webgl/shaders/quad-multi-lit.vert b/packages/melonjs/src/video/webgl/shaders/quad-multi-lit.vert index 16c0987817..9f8474e36e 100644 --- a/packages/melonjs/src/video/webgl/shaders/quad-multi-lit.vert +++ b/packages/melonjs/src/video/webgl/shaders/quad-multi-lit.vert @@ -9,6 +9,10 @@ in vec3 aVertex; in vec2 aRegion; in vec4 aColor; in float aTextureId; +// the UV of the quad's opposite corner: with this vertex's own, the frame of +// the texture the quad draws (see shaders/frameclamp.js). Far below zero for +// a quad with no frame to keep to. Fifth, as in every sprite layout. +in vec2 aFrame; in float aNormalTextureId; // specular exponent for this quad, 0 = matte (the default for every sprite // that does not opt in) @@ -27,6 +31,8 @@ out float vShininess; // compute `lightPos - fragmentPos` for each Light2d. 2D — light math is // in the screen plane, depth doesn't participate. out vec2 vWorldPos; +// the frame (left, top, right, bottom), the same at all four corners +out vec4 vFrame; void main(void) { gl_Position = uProjectionMatrix * vec4(aVertex, 1.0); @@ -36,4 +42,7 @@ void main(void) { vNormalTextureId = aNormalTextureId; vShininess = aShininess; vWorldPos = aVertex.xy; + vFrame = aFrame.x < -5.0e5 + ? vec4(-1.0e6, -1.0e6, 1.0e6, 1.0e6) + : vec4(min(aRegion, aFrame), max(aRegion, aFrame)); } diff --git a/packages/melonjs/src/video/webgl/shaders/quad-multi.vert b/packages/melonjs/src/video/webgl/shaders/quad-multi.vert index 2a78477c4e..001382cb0b 100644 --- a/packages/melonjs/src/video/webgl/shaders/quad-multi.vert +++ b/packages/melonjs/src/video/webgl/shaders/quad-multi.vert @@ -1,17 +1,26 @@ +#version 300 es // Current vertex point — z carries per-sprite depth (renderable.depth), // has no visible effect under the default ortho projection but is required // for perspective projection (Camera3d) to scale sprites by distance. -attribute vec3 aVertex; -attribute vec2 aRegion; -attribute vec4 aColor; -attribute float aTextureId; +in vec3 aVertex; +in vec2 aRegion; +in vec4 aColor; +in float aTextureId; +// the UV of the quad's opposite corner: with this vertex's own, the frame of +// the texture the quad draws (see shaders/frameclamp.js). Far below zero for +// a quad with no frame to keep to. Fifth in every sprite layout: after the +// attributes a custom shader declares, so those keep their locations, and +// before anything a layout adds (the lit extras) +in vec2 aFrame; // Projection matrix uniform mat4 uProjectionMatrix; -varying vec2 vRegion; -varying vec4 vColor; -varying float vTextureId; +out vec2 vRegion; +out vec4 vColor; +out float vTextureId; +// the frame (left, top, right, bottom), the same at all four corners +out vec4 vFrame; void main(void) { // Transform the vertex position by the projection matrix @@ -20,4 +29,7 @@ void main(void) { vColor = vec4(aColor.bgr * aColor.a, aColor.a); vRegion = aRegion; vTextureId = aTextureId; + vFrame = aFrame.x < -5.0e5 + ? vec4(-1.0e6, -1.0e6, 1.0e6, 1.0e6) + : vec4(min(aRegion, aFrame), max(aRegion, aFrame)); } diff --git a/packages/melonjs/src/video/webgl/utils/uniforms.js b/packages/melonjs/src/video/webgl/utils/uniforms.js index f6e3b78833..d007780e19 100644 --- a/packages/melonjs/src/video/webgl/utils/uniforms.js +++ b/packages/melonjs/src/video/webgl/utils/uniforms.js @@ -39,7 +39,7 @@ const fnHash = { * @ignore * @internal */ -function valuesMatch(cached, val) { +export function valuesMatch(cached, val) { if (cached === undefined) { return false; } diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 91ec84e4ab..e9d472b586 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -1381,6 +1381,31 @@ export default class WebGLRenderer extends Renderer { super.drawTileLayer(layer, rect); } + /** + * A tile layer drawn tile by tile: the sprite shaders keep each tile's + * samples half a texel inside it (see `gpu/frame.js`), so two tiles + * never blend the next tile of their tileset in along their edge. + * @ignore + * @internal + */ + beginTileLayer() { + this.drawingTiles = true; + this.batchers.forEach((batcher) => { + batcher.syncFrameClamp?.(); + }); + } + + /** + * @ignore + * @internal + */ + endTileLayer() { + this.drawingTiles = false; + this.batchers.forEach((batcher) => { + batcher.syncFrameClamp?.(); + }); + } + /** * Lazy-init the orientation-specific GPU tilemap renderer. * @param {string} orientation @@ -2684,11 +2709,12 @@ export default class WebGLRenderer extends Renderer { this.batchers.has("litQuad") && this.activeLightCount > 0 && this.currentNormalMap !== null; - this.setBatcher(useLit ? "litQuad" : "quad"); - + // the sprite's own effect, or the default program. Left bound after + // the quad: the next sprite drawn with the same effect joins the + // same draw, and one drawn without it puts the default back here const shader = this.customShader; + this.setBatcher(useLit ? "litQuad" : "quad", shader); if (shader != null) { - this.currentBatcher.useShader(shader); // (re)bind any extra textures a ShaderEffect declared via setTexture shader._prepareTextures?.(this.currentBatcher); } @@ -2743,10 +2769,6 @@ export default class WebGLRenderer extends Renderer { reupload, ); } - - if (typeof shader === "object") { - this.currentBatcher.useShader(this.currentBatcher.defaultShader); - } } /** @@ -2766,10 +2788,9 @@ export default class WebGLRenderer extends Renderer { if (this.customShader?._screenTextureUniforms?.length > 0) { this.toFrameTexture(); } - this.setBatcher("quad"); const shader = this.customShader; + this.setBatcher("quad", shader); if (shader != null) { - this.currentBatcher.useShader(shader); shader._prepareTextures?.(this.currentBatcher); } // computed last: it is a scratch array, valid until the next call @@ -3462,12 +3483,21 @@ export default class WebGLRenderer extends Renderer { * decoupled from {@link WebGLRenderer#setAntiAlias} (which controls MSAA). * Records the setting (via the base) then re-filters every currently-bound * texture. WebGL only. - * @param {"auto"|"nearest"|"linear"} [mode="auto"] - `"auto"` follows `antiAlias` + * @param {"auto"|"nearest"|"linear"|"linear-mipmap"} [mode="auto"] - `"auto"` follows `antiAlias` * @see Renderer#getDefaultTextureFilter + * @example + * // smooth, and from the mipmaps wherever something is drawn small + * app.renderer.setTextureFilter("linear-mipmap"); + * // back to the antiAlias setting + * app.renderer.setTextureFilter("auto"); */ setTextureFilter(mode = "auto") { super.setTextureFilter(mode); this._reapplyTextureFilter(this._glTextureFilter()); + // under "linear-mipmap" a sprite keeps its samples inside its frame + this.batchers.forEach((batcher) => { + batcher.syncFrameClamp?.(); + }); } /** diff --git a/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js index c7388a112f..14fe857191 100644 --- a/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js @@ -1,6 +1,7 @@ // the lighting math modules are pure CPU code shared with the GL backend // (published std140 layout, no GL calls) +import { isFramed } from "../../gpu/frame.js"; import { transformQuadCorners } from "../../gpu/quadcorners.ts"; import { BLOCK_BYTES, @@ -64,12 +65,20 @@ export default class WebGPULitQuadBatcher extends WebGPUQuadBatcher { format: "float32", offset: 6 * Float32Array.BYTES_PER_ELEMENT, }, + { + // the UV of the quad's opposite corner: the frame the + // quad draws (see quad-lit.wgsl). Fifth, as in every + // sprite layout + name: "aFrame", + format: "float32x2", + offset: 7 * Float32Array.BYTES_PER_ELEMENT, + }, { // specular exponent, 0 = matte (every sprite that does // not opt in) name: "aShininess", format: "float32", - offset: 7 * Float32Array.BYTES_PER_ELEMENT, + offset: 9 * Float32Array.BYTES_PER_ELEMENT, }, ], }, @@ -274,8 +283,10 @@ export default class WebGPULitQuadBatcher extends WebGPUQuadBatcher { ) { const renderer = this.renderer; // resolve the combined color+normal material for this segment + const mipmaps = this.wantsMipmaps(texture); const record = renderer.textureStore.getResidentRecord(texture, { force: reupload, + mipmaps, }); const normal = this.residentNormalMap(normalMap); @@ -292,12 +303,16 @@ export default class WebGPULitQuadBatcher extends WebGPUQuadBatcher { : renderer.getDefaultTextureFilter(); const wrap = texture.repeat ?? "no-repeat"; const store = renderer.textureStore; + // the color under "linear-mipmap" samples its chain, as on the + // plain quad path; the normal map keeps to its full size (the + // average of several normals is not a normal) + const color = store.viewAndSampler(record, texture, wrap, mipmaps); combined = this.device.createBindGroup({ label: "melonJS lit material", layout: this.litMaterialLayout, entries: [ - { binding: 0, resource: record.view }, - { binding: 1, resource: store.getSampler(filter, wrap) }, + { binding: 0, resource: color.view }, + { binding: 1, resource: color.sampler }, { binding: 2, resource: normal.view }, { binding: 3, resource: store.getSampler(filter, "no-repeat") }, ], @@ -314,7 +329,19 @@ export default class WebGPULitQuadBatcher extends WebGPUQuadBatcher { // smear onto the previous segment's vertices. this.currentQuadShininess = renderer.currentShininess || 0; // transform + push the four corners exactly like the base batcher - this.pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint); + this.pushQuadVertices( + x, + y, + w, + h, + u0, + v0, + u1, + v1, + tint, + 0, + isFramed(texture), + ); } /** @@ -330,10 +357,22 @@ export default class WebGPULitQuadBatcher extends WebGPUQuadBatcher { * @param {number} v1 - texture UV (v1) * @param {number} tint - tint color in UINT32 (argb) format * @param {number} [textureId=0] - the segment slot + * @param {boolean} [framed=true] - false when the quad has no frame to keep to * @override */ - pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint, textureId = 0) { - const vertexData = this.vertexData; + pushQuadVertices( + x, + y, + w, + h, + u0, + v0, + u1, + v1, + tint, + textureId = 0, + framed = true, + ) { const shininess = this.currentQuadShininess || 0; const [vec0, vec1, vec2, vec3] = transformQuadCorners( this.renderer.currentTransform, @@ -343,10 +382,20 @@ export default class WebGPULitQuadBatcher extends WebGPUQuadBatcher { h, this.renderer.currentDepth, ); - vertexData.push(vec0.x, vec0.y, vec0.z, u0, v0, tint, textureId, shininess); - vertexData.push(vec1.x, vec1.y, vec1.z, u1, v0, tint, textureId, shininess); - vertexData.push(vec2.x, vec2.y, vec2.z, u0, v1, tint, textureId, shininess); - vertexData.push(vec3.x, vec3.y, vec3.z, u1, v1, tint, textureId, shininess); + this.vertexData.pushSpriteQuad( + vec0, + vec1, + vec2, + vec3, + u0, + v0, + u1, + v1, + tint, + textureId, + framed, + shininess, + ); } /** diff --git a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js index d02faeb3d5..5bae279472 100644 --- a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js @@ -1,4 +1,5 @@ import IndexBuffer from "../../buffer/index.js"; +import { FRAME_UNBOUNDED, isFramed } from "../../gpu/frame.js"; import { scaleQuad, transformQuadCorners } from "../../gpu/quadcorners.ts"; import { TextureSlotTable } from "../../gpu/textureslots.js"; import { prepareEffectBinding } from "../effect_binding.js"; @@ -7,7 +8,7 @@ import WebGPUBatcher from "./webgpu_batcher.js"; /** * The WebGPU quad batcher — textured-quad accumulation with the same - * frozen 28-byte vertex layout as the WebGL `QuadBatcher` and the same + * frozen 36-byte vertex layout as the WebGL `QuadBatcher` and the same * static 6-indices-per-quad pattern. * * Texture batching is single-texture-per-draw-segment in this first @@ -35,7 +36,7 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { attributes: [ { // vec3: (x, y, z). z carries `renderable.depth` for - // perspective projection (Camera3d). Stride = 28 bytes. + // perspective projection (Camera3d). Stride = 36 bytes. name: "aVertex", format: "float32x3", offset: 0 * Float32Array.BYTES_PER_ELEMENT, @@ -55,6 +56,15 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { format: "float32", offset: 6 * Float32Array.BYTES_PER_ELEMENT, }, + { + // the UV of the quad's opposite corner: the frame the + // quad draws (see quad.wgsl). After the attributes a + // module declares, so those keep their locations; the + // lit layout keeps it here too, its extra after it + name: "aFrame", + format: "float32x2", + offset: 7 * Float32Array.BYTES_PER_ELEMENT, + }, ], }, ); @@ -70,6 +80,18 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { // the backdrop (no offscreen target) this.currentEffect = null; + /** + * draws the quads queued through the current effect: what one of + * its uniforms calls before taking a new value (see `adoptEffect`) + * @ignore + * @internal + */ + this.drawQueuedEffectQuads = () => { + if (this.vertexData.vertexCount > 0) { + this.flush(); + } + }; + // multi-texture segment state: up to MAX_QUAD_TEXTURES distinct // (texture view, sampler) pairs share one draw segment, selected // per quad by aTextureId — a flush is forced only by the NINTH @@ -99,7 +121,7 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { /** @type {{view: GPUTextureView, sampler: GPUSampler}[]} indexed by slot */ this.segmentEntries = []; /** - * last (view, filter, wrap) resolved to a segment slot + * last (view, filter, wrap, mipmaps) resolved to a segment slot * @ignore * @internal */ @@ -114,6 +136,11 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { * @internal */ this._memoWrap = null; + /** + * @ignore + * @internal + */ + this._memoMipmaps = false; /** * @ignore * @internal @@ -176,7 +203,7 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { const effect = renderer.customShader ?? null; if (effect !== this.currentEffect) { this.flush(); - this.currentEffect = effect; + this.adoptEffect(effect); } if (effect !== null) { @@ -192,6 +219,7 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { // its own anyway const bindGroup = renderer.textureStore.getBinding(texture, { force: reupload, + mipmaps: this.wantsMipmaps(texture), }); if (bindGroup !== this.currentMaterial) { this.flush(); @@ -213,22 +241,39 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { ); // and keep its samples inside that frame: on an atlas, the // texture goes on into the neighbouring frames - effect._setFrameRect?.(u0, v0, u1, v1, sourceWidth, sourceHeight); + effect._setFrameTexel?.(sourceWidth, sourceHeight); // the effect's vertex stage, AFTER the frame rect above, which // must stay the sprite's own const scale = effect._vertexScale; + const framed = isFramed(texture); if (scale !== undefined && (scale.x !== 1 || scale.y !== 1)) { const q = scaleQuad(x, y, w, h, scale.x, scale.y); - this.pushQuadVertices(q[0], q[1], q[2], q[3], u0, v0, u1, v1, tint, 0); + this.pushQuadVertices( + q[0], + q[1], + q[2], + q[3], + u0, + v0, + u1, + v1, + tint, + 0, + framed, + ); } else { - this.pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint, 0); + this.pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint, 0, framed); } - // per-quad draw under the fast path: each sprite needs its own - // capture state, noise rect and uniform snapshot (draw-time - // setUniform mutation included) - this.flush(); + // the next sprite drawn through the same effect joins this draw: + // a new uniform value, noise rect or texture draws the queued + // quads first (see `adoptEffect`). Only a `screen_texture` effect + // draws each sprite on its own, as each one samples a capture of + // everything drawn before it + if (effect._screenTextureUniforms?.length > 0) { + this.flush(); + } return; } @@ -245,9 +290,51 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { v1, tint, this.segmentSlotFor(texture, reupload), + isFramed(texture), ); } + /** + * Make `effect` the one the pending quads draw through (or none), and + * have a change to its uniforms draw the quads queued under the old + * values first: its uniforms are uploaded when the quads draw, so they + * would otherwise all draw with the last value set. + * @param {object|null} effect - the ShaderEffect, or null + * @ignore + * @internal + */ + adoptEffect(effect) { + const previous = this.currentEffect?.wgslRealization; + if (previous && previous.onUniformChange === this.drawQueuedEffectQuads) { + previous.onUniformChange = null; + } + this.currentEffect = effect; + const realization = effect?.wgslRealization; + if (realization) { + realization.onUniformChange = this.drawQueuedEffectQuads; + } + } + + /** + * Whether a quad's texture samples its mipmaps: the `"linear-mipmap"` + * filter, over a linear texture whose source keeps a chain. + * @param {object} texture - the texture atlas drawn + * @returns {boolean} true to ask the store for the mip chain + * @ignore + * @internal + */ + wantsMipmaps(texture) { + const renderer = this.renderer; + if (!renderer.wantsMipmaps(texture)) { + return false; + } + const filter = + typeof texture.filter === "string" + ? texture.filter + : renderer.getDefaultTextureFilter(); + return filter === "linear"; + } + /** * Resolve a texture to its slot in the pending segment, claiming a new * slot (and flushing a full segment) when this (view, sampler) pair is @@ -268,7 +355,11 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { // against `texture.getTexture()` and re-uploads when the source has // changed underneath (a video frame, an animated canvas, a swapped // atlas). Memoizing past it would serve stale pixels. - const record = store.getResidentRecord(texture, { force: reupload }); + const mipmaps = this.wantsMipmaps(texture); + const record = store.getResidentRecord(texture, { + force: reupload, + mipmaps, + }); const wrap = texture.repeat ?? "no-repeat"; const filter = typeof texture.filter === "string" @@ -289,20 +380,25 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { if ( record.view === this._memoView && filter === this._memoFilter && - wrap === this._memoWrap + wrap === this._memoWrap && + mipmaps === this._memoMipmaps ) { return this._memoSlot; } - const slotKey = `${this.resourceId(record.view)}|${filter}|${wrap}`; + // the view and sampler the store pairs for this use: the mip chain + // only for a linear texture that has one (a compressed texture's + // authored chain is a separate view) + const pair = store.viewAndSampler(record, texture, wrap, mipmaps); + const slotKey = `${this.resourceId(pair.view)}|${pair.key}`; // a hit returns the live slot; a miss claims one, flushing the pending // segment first when the table is full (the `onOverflow` above) let resolved = this.slotTable.peek(slotKey); if (resolved === undefined) { resolved = this.slotTable.slotFor(slotKey); this.segmentEntries[resolved] = { - view: record.view, - sampler: store.getSampler(filter, wrap), + view: pair.view, + sampler: pair.sampler, }; this.segmentGroup = null; } @@ -310,6 +406,7 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { this._memoView = record.view; this._memoFilter = filter; this._memoWrap = wrap; + this._memoMipmaps = mipmaps; this._memoSlot = resolved; return resolved; } @@ -400,12 +497,24 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { * the vertex; for 2D-only matrices the z column is identity, so the * output (x, y) is bit-identical and z passes through. textureId is the * quad's segment slot (constant across its four corners); the lit and - * fast paths pass 0 (single-texture bind groups). + * fast paths pass 0 (single-texture bind groups). Each corner carries the + * UV of the opposite one, the quad's frame, unless `framed` is false. * @ignore * @internal */ - pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint, textureId = 0) { - const vertexData = this.vertexData; + pushQuadVertices( + x, + y, + w, + h, + u0, + v0, + u1, + v1, + tint, + textureId = 0, + framed = true, + ) { const [vec0, vec1, vec2, vec3] = transformQuadCorners( this.renderer.currentTransform, x, @@ -416,10 +525,19 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { ); // 4 vertices per quad; the index buffer provides the 6 indices - vertexData.push(vec0.x, vec0.y, vec0.z, u0, v0, tint, textureId); - vertexData.push(vec1.x, vec1.y, vec1.z, u1, v0, tint, textureId); - vertexData.push(vec2.x, vec2.y, vec2.z, u0, v1, tint, textureId); - vertexData.push(vec3.x, vec3.y, vec3.z, u1, v1, tint, textureId); + this.vertexData.pushSpriteQuad( + vec0, + vec1, + vec2, + vec3, + u0, + v0, + u1, + v1, + tint, + textureId, + framed, + ); } /** @@ -477,12 +595,15 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { } // one quad, UNFLIPPED UVs (WebGPU texture row 0 is the top — the GL - // blit flips V because GL FBOs are bottom-up), white tint + // blit flips V because GL FBOs are bottom-up), white tint, and the + // whole target drawn: no frame const vertexData = this.vertexData; - vertexData.push(x, y, 0, 0, 0, 0xffffffff, 0); - vertexData.push(x + w, y, 0, 1, 0, 0xffffffff, 0); - vertexData.push(x, y + h, 0, 0, 1, 0xffffffff, 0); - vertexData.push(x + w, y + h, 0, 1, 1, 0xffffffff, 0); + const open = FRAME_UNBOUNDED; + const tint = 0xffffffff; + vertexData.pushSprite(x, y, 0, 0, 0, tint, 0, open, open); + vertexData.pushSprite(x + w, y, 0, 1, 0, tint, 0, open, open); + vertexData.pushSprite(x, y + h, 0, 0, 1, tint, 0, open, open); + vertexData.pushSprite(x + w, y + h, 0, 1, 1, tint, 0, open, open); const byteLength = 4 * this.stride; const region = renderer.vertexArena.alloc(byteLength); this.device.queue.writeBuffer( @@ -613,7 +734,7 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { */ reset() { super.reset(); - this.currentEffect = null; + this.adoptEffect(null); this.currentMaterial = null; this.resetSegment(); this.composedGroups.clear(); diff --git a/packages/melonjs/src/video/webgpu/buffer/uniformring.js b/packages/melonjs/src/video/webgpu/buffer/uniformring.js index 819509c57f..cacea7e92f 100644 --- a/packages/melonjs/src/video/webgpu/buffer/uniformring.js +++ b/packages/melonjs/src/video/webgpu/buffer/uniformring.js @@ -91,16 +91,19 @@ export default class WebGPUUniformRing { } /** - * Write a frame-globals slot (projection matrix + line width). + * Write a frame-globals slot (projection matrix, line width, and the + * sprite shaders' frame clamp mode, in the block's padding). * @param {import("../../math/matrix3d.ts").Matrix3d} projection - the projection matrix * @param {number} lineWidth - current stroke line width + * @param {number} [frameClamp=0] - 1 to keep sprite samples inside their frame * @returns {{bindGroup: GPUBindGroup, dynamicOffset: number}} the binding for subsequent draws */ - pushFrameGlobals(projection, lineWidth) { + pushFrameGlobals(projection, lineWidth, frameClamp = 0) { const { page, dynamicOffset } = this.#allocSlot(); const scratch = this.scratch; scratch.set(projection.val, 0); scratch[16] = lineWidth; + scratch[17] = frameClamp; this.device.queue.writeBuffer( page.buffer, dynamicOffset, diff --git a/packages/melonjs/src/video/webgpu/renderers/tmxlayer/orthogonal.js b/packages/melonjs/src/video/webgpu/renderers/tmxlayer/orthogonal.js index 1477e61698..f68ff856c3 100644 --- a/packages/melonjs/src/video/webgpu/renderers/tmxlayer/orthogonal.js +++ b/packages/melonjs/src/video/webgpu/renderers/tmxlayer/orthogonal.js @@ -1,3 +1,4 @@ +import VertexArrayBuffer from "../../../buffer/vertex.js"; import { transformQuadCorners } from "../../../gpu/quadcorners.ts"; import tmxLayerWGSL from "../../shaders/tmxlayer.wgsl"; @@ -9,12 +10,13 @@ import tmxLayerWGSL from "../../shaders/tmxlayer.wgsl"; /** * byte size of the TMXUniforms block (see tmxlayer.wgsl): ten vec2f (80) - * interleaved with one vec4f at a 16-aligned offset, two f32 and the - * trailing vec4f tint → 112 bytes + * interleaved with one vec4f at a 16-aligned offset, two f32, the vec4f + * tint (ending at 112) and the mipmap flag, rounded up to the block's + * 16-byte alignment → 128 bytes * @ignore * @internal */ -const TMX_UNIFORM_SIZE = 112; +const TMX_UNIFORM_SIZE = 128; /** * GPU-accelerated renderer for orthogonal TMX tile layers on the WebGPU @@ -89,11 +91,13 @@ export default class OrthogonalTMXLayerGPURenderer { */ this.bindGroups = new Map(); - // CPU staging for the uniform block and the quad vertices + // CPU staging for the uniform block; the quad's vertices are staged + // in the quad batcher's own layout, sized on first draw this.uniformScratch = new Float32Array(TMX_UNIFORM_SIZE / 4); - this.vertexScratch = new ArrayBuffer(4 * 28); - this.vertexF32 = new Float32Array(this.vertexScratch); - this.vertexU32 = new Uint32Array(this.vertexScratch); + /** + * @type {VertexArrayBuffer|undefined} + */ + this.vertexScratch = undefined; } /** @@ -258,8 +262,9 @@ export default class OrthogonalTMXLayerGPURenderer { const resource = this.getResource(layer); - // the transformed quad, shared by every tileset pass (28-byte - // stride: x,y,z, u,v, packed tint, textureId) + // the transformed quad, shared by every tileset pass, in the quad + // batcher's layout (its pipelines ride that layout). The shader keeps + // to its own tiles, so the quad carries no frame const corners = transformQuadCorners( renderer.currentTransform, worldX, @@ -268,26 +273,32 @@ export default class OrthogonalTMXLayerGPURenderer { worldH, renderer.currentDepth, ); - const f32 = this.vertexF32; - const u32 = this.vertexU32; - const uvs = [0, 0, 1, 0, 0, 1, 1, 1]; - for (let i = 0; i < 4; i++) { - const base = i * 7; - f32[base] = corners[i].x; - f32[base + 1] = corners[i].y; - f32[base + 2] = corners[i].z; - f32[base + 3] = uvs[i * 2]; - f32[base + 4] = uvs[i * 2 + 1]; - u32[base + 5] = 0xffffffff; - f32[base + 6] = 0; + if (this.vertexScratch?.vertexSize !== batcher.vertexSize) { + this.vertexScratch = new VertexArrayBuffer(batcher.vertexSize, 4); } - const vertexRegion = renderer.vertexArena.alloc(4 * 28); + const vertices = this.vertexScratch; + vertices.clear(); + vertices.pushSpriteQuad( + corners[0], + corners[1], + corners[2], + corners[3], + 0, + 0, + 1, + 1, + 0xffffffff, + 0, + false, + ); + const byteLength = 4 * batcher.stride; + const vertexRegion = renderer.vertexArena.alloc(byteLength); device.queue.writeBuffer( vertexRegion.buffer, vertexRegion.offset, - this.vertexScratch, + vertices.toUint8(), 0, - 4 * 28, + byteLength, ); // layer-level uniform values (identical for every tileset pass) @@ -300,7 +311,9 @@ export default class OrthogonalTMXLayerGPURenderer { scratch[19] = startTileY; scratch[20] = endTileX - startTileX; scratch[21] = endTileY - startTileY; - scratch[22] = layer.getOpacity(); + // the layer's opacity, and the fade of everything it is drawn in: + // set on the renderer by the layer's preDraw + scratch[22] = renderer.getGlobalAlpha(); const tint = layer.tint ? layer.tint.toArray() : null; scratch[24] = tint ? tint[0] : 1; scratch[25] = tint ? tint[1] : 1; @@ -321,7 +334,12 @@ export default class OrthogonalTMXLayerGPURenderer { const frame = renderer.currentFrameBinding; pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]); pass.setBindGroup(2, renderer.pipelineCache.emptyBindGroup); - pass.setVertexBuffer(0, vertexRegion.buffer, vertexRegion.offset, 4 * 28); + pass.setVertexBuffer( + 0, + vertexRegion.buffer, + vertexRegion.offset, + byteLength, + ); pass.setIndexBuffer(batcher.indexBuffer, "uint32"); // one pass per tileset — the GID-range guard hides other tilesets @@ -377,6 +395,16 @@ export default class OrthogonalTMXLayerGPURenderer { scratch[16] = tileset.firstgid; scratch[17] = tileset.lastgid; scratch[23] = animEntry ? animEntry.tileCount : 0; + // the "linear-mipmap" filter, on a linear tileset: the binding + // below asks the store for a chain, and the shader keeps each + // sample clear of the neighbouring tiles at the level it reads + const filter = + typeof tileset.texture.filter === "string" + ? tileset.texture.filter + : renderer.getDefaultTextureFilter(); + const mipmaps = + filter === "linear" && renderer.wantsMipmaps(tileset.texture); + scratch[28] = mipmaps ? 1 : 0; const region = renderer.effectUniformArena.alloc( TMX_UNIFORM_SIZE, device.limits?.minUniformBufferOffsetAlignment ?? 256, @@ -419,7 +447,10 @@ export default class OrthogonalTMXLayerGPURenderer { layerGroups.set(tileset, cached); } - pass.setBindGroup(1, renderer.textureStore.getBinding(tileset.texture)); + pass.setBindGroup( + 1, + renderer.textureStore.getBinding(tileset.texture, { mipmaps }), + ); pass.setBindGroup(3, cached.bindGroup, [region.offset]); pass.drawIndexed(6); } diff --git a/packages/melonjs/src/video/webgpu/shaders/blit.wgsl b/packages/melonjs/src/video/webgpu/shaders/blit.wgsl index 8c288c93ac..1fa74d9bba 100644 --- a/packages/melonjs/src/video/webgpu/shaders/blit.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/blit.wgsl @@ -4,11 +4,12 @@ // single-texture group-1 shape the effect scaffold shares (the QUAD family // batches across eight textures and has its own group-1 layout). // -// Vertex layout (28-byte stride, frozen, shared with the WebGL backend): +// Vertex layout (36-byte stride, frozen, shared with the WebGL backend): // @location(0) aVertex float32x3 offset 0 // @location(1) aRegion float32x2 offset 12 // @location(2) aColor unorm8x4 offset 20 // @location(3) aTextureId float32 offset 24 (unused here) +// @location(4) aFrame float32x2 offset 28 (unused here) // // aColor arrives from a packed ARGB uint32 (Color.toUint32). Little-endian // memory bytes are [B,G,R,A] and unorm8x4 maps byte i -> component i, so the diff --git a/packages/melonjs/src/video/webgpu/shaders/quad-lit.wgsl b/packages/melonjs/src/video/webgpu/shaders/quad-lit.wgsl index 9f3d855800..c6730c8f83 100644 --- a/packages/melonjs/src/video/webgpu/shaders/quad-lit.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/quad-lit.wgsl @@ -5,14 +5,18 @@ // The std140 Light2dBlock binds at group 2 with a dynamic offset — one // snapshot per setLightUniforms call, per the queue-write ordering law. // -// Vertex layout: this family's OWN 32-byte `litQuad` layout — the frozen -// quad layout plus a per-quad `aShininess`, so unlit sprites are not charged -// for a term they never evaluate. +// Vertex layout: this family's OWN 40-byte `litQuad` layout — the quad +// layout (`aFrame` fifth, as in every sprite layout), then a per-quad +// `aShininess`, so unlit sprites are not charged for a term they never +// evaluate. Under "linear-mipmap" the color and normal samples stay inside +// the quad's frame, as in quad.wgsl. struct FrameUniforms { projection : mat4x4, // unused by this shader; part of the shared frame-globals block lineWidth : f32, + // how each sample keeps inside the quad's frame (quad.wgsl, gpu/frame.js) + frameClamp : f32, }; struct Light2dData { @@ -47,6 +51,10 @@ struct VSOut { @location(2) vWorldPos : vec2f, // specular exponent, flat across the quad; 0 = matte @location(3) vShininess : f32, + // the quad's frame (left, top, right, bottom), the same at all corners + @location(4) vFrame : vec4f, + // 1 when the clamp follows the mipmap level read, 0 at the full size + @location(5) @interpolate(flat) vFrameLevels : f32, }; @vertex @@ -55,7 +63,8 @@ fn vertex_main( @location(1) aRegion : vec2f, @location(2) aColor : vec4f, @location(3) aTextureId : f32, - @location(4) aShininess : f32, + @location(4) aFrame : vec2f, + @location(5) aShininess : f32, ) -> VSOut { var out : VSOut; let clip = uFrame.projection * vec4f(aVertex, 1.0); @@ -65,14 +74,42 @@ fn vertex_main( out.vRegion = aRegion; out.vWorldPos = aVertex.xy; out.vShininess = aShininess; + // unbounded when the quad has no frame, or the clamp is off (the frame + // globals are bound to the vertex stage only) + out.vFrame = select( + vec4f(min(aRegion, aFrame), max(aRegion, aFrame)), + vec4f(-1.0e6, -1.0e6, 1.0e6, 1.0e6), + aFrame.x < -5.0e5 || uFrame.frameClamp < 0.5, + ); + out.vFrameLevels = select(0.0, 1.0, uFrame.frameClamp > 1.5); return out; } +// `uv` kept inside `frame`, half a texel of the deepest level read in from +// its edge, unless the frame is unbounded (quad.wgsl's twin) +fn frameUV(uv : vec2f, frame : vec4f, size : vec2f, dx : vec2f, dy : vec2f, levels : f32) -> vec2f { + if (frame.x < -5.0e5) { + return uv; + } + let lod = select( + 0.0, + ceil(log2(max(max(length(dx * size), length(dy * size)), 1.0)) - 0.01), + levels > 0.5, + ); + let inset = min(vec2f(0.5 * exp2(lod)) / size, (frame.zw - frame.xy) * 0.5); + return clamp(uv, frame.xy + inset, frame.zw - inset); +} + @fragment fn fragment_main(in : VSOut) -> @location(0) vec4f { - let color = textureSample(uTexture, uSampler, in.vRegion); - // the normal map is UV-paired with the color atlas — same vRegion - let normalSample = textureSample(uNormal, uNormalSampler, in.vRegion); + // sampled with the unclamped UV's derivatives, so a clamped sample reads + // the level the unclamped one would + let dx = dpdx(in.vRegion); + let dy = dpdy(in.vRegion); + let uv = frameUV(in.vRegion, in.vFrame, vec2f(textureDimensions(uTexture, 0)), dx, dy, in.vFrameLevels); + let color = textureSampleGrad(uTexture, uSampler, uv, dx, dy); + // the normal map is UV-paired with the color atlas — same frame + let normalSample = textureSampleGrad(uNormal, uNormalSampler, uv, dx, dy); // Decode 0..1 -> -1..1. Normal maps use the Y-up authoring convention // but screen space here is Y-down — flip Y so dot(normal, lightDir) diff --git a/packages/melonjs/src/video/webgpu/shaders/quad.wgsl b/packages/melonjs/src/video/webgpu/shaders/quad.wgsl index 6532758568..ea6a977cec 100644 --- a/packages/melonjs/src/video/webgpu/shaders/quad.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/quad.wgsl @@ -4,26 +4,41 @@ // flushes only when a NINTH distinct texture appears (or on the usual // blend/effect/capacity boundaries) — the GL sampler-ladder equivalent. // -// Vertex layout (28-byte stride, frozen, shared with the WebGL backend): +// Vertex layout (36-byte stride, frozen, shared with the WebGL backend): // @location(0) aVertex float32x3 offset 0 // @location(1) aRegion float32x2 offset 12 // @location(2) aColor unorm8x4 offset 20 // @location(3) aTextureId float32 offset 24 (slot index, 0..7) +// @location(4) aFrame float32x2 offset 28 (the opposite corner's UV) // // aColor arrives from a packed ARGB uint32 (Color.toUint32). Little-endian // memory bytes are [B,G,R,A] and unorm8x4 maps byte i -> component i, so the // attribute reads (B,G,R,A) — the same .bgr swizzle as the GLSL sources // reconstructs RGB, premultiplied by alpha. // -// Sampling uses textureSampleLevel(…, 0.0): the slot index is not uniform -// across the draw, which bars implicit-derivative textureSample under WGSL's -// uniformity analysis — and 2D quads sample level 0 by contract anyway -// (their samplers are lod-clamped; mip chains are a mesh-path feature). +// Sampling uses textureSampleGrad with derivatives taken before the slot +// switch: the slot index is not uniform across the draw, which bars an +// implicit-derivative textureSample under WGSL's uniformity analysis. A slot +// samples its mip chain only under the "linear-mipmap" filter; every other +// sampler is lod-clamped to level 0. +// +// Under that filter each sample also stays inside the quad's frame, half a +// texel of the deepest level it reads in from the frame's edge: a sprite cut +// from an atlas or a tileset draws one frame of a bigger texture, and a +// small level's texels reach into the next frame. The frame is the vertex's +// own UV and `aFrame`, the opposite corner's; a quad with no frame (a +// repeating pattern) carries a sentinel far below zero and is left alone. +// GL twin: webgl/shaders/frameclamp.js. struct FrameUniforms { projection : mat4x4, // unused by this shader; part of the shared frame-globals block lineWidth : f32, + // how each sample keeps inside the quad's frame (gpu/frame.js): 0 not, + // 1 half a texel of the full-size art (a tile layer drawn tile by tile), + // 2 half a texel of the level read ("linear-mipmap"). Rides in the + // block's padding: the struct is 80 bytes either way + frameClamp : f32, }; @group(0) @binding(0) var uFrame : FrameUniforms; @@ -51,6 +66,11 @@ struct VSOut { // constant across a quad's four vertices, so plain interpolation // reproduces it exactly; rounded back to the slot index per fragment @location(2) vTextureId : f32, + // the quad's frame (left, top, right, bottom), the same at all four + // corners + @location(3) vFrame : vec4f, + // 1 when the clamp follows the mipmap level read, 0 at the full size + @location(4) @interpolate(flat) vFrameLevels : f32, }; @vertex @@ -59,6 +79,7 @@ fn vertex_main( @location(1) aRegion : vec2f, @location(2) aColor : vec4f, @location(3) aTextureId : f32, + @location(4) aFrame : vec2f, ) -> VSOut { var out : VSOut; let clip = uFrame.projection * vec4f(aVertex, 1.0); @@ -69,37 +90,70 @@ fn vertex_main( out.vColor = vec4f(aColor.bgr * aColor.a, aColor.a); out.vRegion = aRegion; out.vTextureId = aTextureId; + // unbounded when the quad has no frame, or the clamp is off (the frame + // globals are bound to the vertex stage only) + out.vFrame = select( + vec4f(min(aRegion, aFrame), max(aRegion, aFrame)), + vec4f(-1.0e6, -1.0e6, 1.0e6, 1.0e6), + aFrame.x < -5.0e5 || uFrame.frameClamp < 0.5, + ); + out.vFrameLevels = select(0.0, 1.0, uFrame.frameClamp > 1.5); return out; } +// `uv` kept inside `frame`, half a texel of the deepest level read in from +// its edge, unless the frame is unbounded (no frame, or the clamp off). +// `size` is the texture's level-0 size +fn frameUV(uv : vec2f, frame : vec4f, size : vec2f, dx : vec2f, dy : vec2f, levels : f32) -> vec2f { + if (frame.x < -5.0e5) { + return uv; + } + // the next level up once past a whole level, by more than a rounding + // error: drawn at its size, the derivatives are 1 give or take a few + // ulps. Without mipmaps, the full-size art is the only level + let lod = select( + 0.0, + ceil(log2(max(max(length(dx * size), length(dy * size)), 1.0)) - 0.01), + levels > 0.5, + ); + // half a texel of it, never past the frame's centre + let inset = min(vec2f(0.5 * exp2(lod)) / size, (frame.zw - frame.xy) * 0.5); + return clamp(uv, frame.xy + inset, frame.zw - inset); +} + @fragment fn fragment_main(in : VSOut) -> @location(0) vec4f { let uv = in.vRegion; + // explicit gradients, taken here while control flow is uniform: the + // slot switch below is not, which bars an implicit-derivative sample. + // A slot's sampler without mipmaps clamps to level 0, as before + let dx = dpdx(uv); + let dy = dpdy(uv); var color : vec4f; switch i32(in.vTextureId + 0.5) { case 1: { - color = textureSampleLevel(uTexture1, uSampler1, uv, 0.0); + color = textureSampleGrad(uTexture1, uSampler1, frameUV(uv, in.vFrame, vec2f(textureDimensions(uTexture1, 0)), dx, dy, in.vFrameLevels), dx, dy); } case 2: { - color = textureSampleLevel(uTexture2, uSampler2, uv, 0.0); + color = textureSampleGrad(uTexture2, uSampler2, frameUV(uv, in.vFrame, vec2f(textureDimensions(uTexture2, 0)), dx, dy, in.vFrameLevels), dx, dy); } case 3: { - color = textureSampleLevel(uTexture3, uSampler3, uv, 0.0); + color = textureSampleGrad(uTexture3, uSampler3, frameUV(uv, in.vFrame, vec2f(textureDimensions(uTexture3, 0)), dx, dy, in.vFrameLevels), dx, dy); } case 4: { - color = textureSampleLevel(uTexture4, uSampler4, uv, 0.0); + color = textureSampleGrad(uTexture4, uSampler4, frameUV(uv, in.vFrame, vec2f(textureDimensions(uTexture4, 0)), dx, dy, in.vFrameLevels), dx, dy); } case 5: { - color = textureSampleLevel(uTexture5, uSampler5, uv, 0.0); + color = textureSampleGrad(uTexture5, uSampler5, frameUV(uv, in.vFrame, vec2f(textureDimensions(uTexture5, 0)), dx, dy, in.vFrameLevels), dx, dy); } case 6: { - color = textureSampleLevel(uTexture6, uSampler6, uv, 0.0); + color = textureSampleGrad(uTexture6, uSampler6, frameUV(uv, in.vFrame, vec2f(textureDimensions(uTexture6, 0)), dx, dy, in.vFrameLevels), dx, dy); } case 7: { - color = textureSampleLevel(uTexture7, uSampler7, uv, 0.0); + color = textureSampleGrad(uTexture7, uSampler7, frameUV(uv, in.vFrame, vec2f(textureDimensions(uTexture7, 0)), dx, dy, in.vFrameLevels), dx, dy); } default: { - color = textureSampleLevel(uTexture0, uSampler0, uv, 0.0); + color = textureSampleGrad(uTexture0, uSampler0, frameUV(uv, in.vFrame, vec2f(textureDimensions(uTexture0, 0)), dx, dy, in.vFrameLevels), dx, dy); } } return color * in.vColor; diff --git a/packages/melonjs/src/video/webgpu/shaders/tmxlayer.wgsl b/packages/melonjs/src/video/webgpu/shaders/tmxlayer.wgsl index 2d88ec74f7..d892c921bb 100644 --- a/packages/melonjs/src/video/webgpu/shaders/tmxlayer.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/tmxlayer.wgsl @@ -37,6 +37,7 @@ struct TMXUniforms { uOpacity : f32, uAnimSize : f32, // entries in the anim lookup, 0 when disabled uTint : vec4f, + uMipmaps : f32, // 1 under the "linear-mipmap" filter, else 0 }; @group(0) @binding(0) var uFrame : FrameUniforms; @@ -78,7 +79,18 @@ struct CellSample { // Try to render the tile at cell (cx, cy) for the current fragment — // identical logic to the GLSL tryRenderCell, returning a hit/color pair // (WGSL has no out parameters). -fn tryRenderCell(cx : i32, cy : i32, worldPx : vec2f) -> CellSample { +// `dWorldX` / `dWorldY`: the world position's screen derivatives, taken in +// `fragment_main` while control flow is still uniform. The atlas coordinate +// jumps from one tile to the next, so its own derivatives would pick a tiny +// mip level along every tile edge; a texel of the atlas is a world pixel, +// so the world's derivatives are the atlas's. +fn tryRenderCell( + cx : i32, + cy : i32, + worldPx : vec2f, + dWorldX : vec2f, + dWorldY : vec2f, +) -> CellSample { var miss : CellSample; miss.hit = false; miss.color = vec4f(0.0); @@ -134,10 +146,29 @@ fn tryRenderCell(cx : i32, cy : i32, worldPx : vec2f) -> CellSample { let col = f32(localId) - row * tmx.uTilesetCols.x; let tileOriginPx = tmx.uTilesetMargin.xy + vec2f(col, row) * (tmx.uTileSize + tmx.uTilesetMargin.zw); - let texelPx = tileOriginPx + inTile * tmx.uTileSize; + var texelPx = tileOriginPx + inTile * tmx.uTileSize; + // keep the sample half a texel of the level it reads inside the tile, + // so filtering never blends in the next tile of the tileset, or the + // spacing around it, along the tile's edge: a seam between two tiles + // (never past the tile's centre). Without mipmaps that level is the + // full-size art; under them, the deepest level read is the next one up + // once the level is past a whole number, by more than a rounding error: + // drawn at its size, the derivatives are 1 give or take a few ulps, and + // level 0 it is. Sampled nearest, the sample stays in the same texel + let lod = ceil(log2(max(max(length(dWorldX), length(dWorldY)), 1.0)) - 0.01); + let inset = min(vec2f(0.5 * exp2(lod * tmx.uMipmaps)), tmx.uTileSize * 0.5); + texelPx = clamp(texelPx, tileOriginPx + inset, tileOriginPx + tmx.uTileSize - inset); let texelUV = texelPx * tmx.uInvTilesetSize; - let sampled = textureSampleLevel(uTexture, uSampler, texelUV, 0.0); + // explicit gradients, so the sample is legal here (non-uniform control + // flow); a sampler without mipmaps clamps to level 0, as before + let sampled = textureSampleGrad( + uTexture, + uSampler, + texelUV, + dWorldX * tmx.uInvTilesetSize, + dWorldY * tmx.uInvTilesetSize, + ); if (sampled.a <= 0.0) { return miss; } @@ -147,11 +178,21 @@ fn tryRenderCell(cx : i32, cy : i32, worldPx : vec2f) -> CellSample { return out; } +// the layer's opacity and tint applied to a texel. The texel is +// premultiplied, as the blending expects: the opacity scales its color as +// well as its alpha. The tint colors it; its alpha is not an opacity, as on +// a sprite +fn layerColor(texel : vec4f) -> vec4f { + return texel * tmx.uOpacity * vec4f(tmx.uTint.rgb, 1.0); +} + @fragment fn fragment_main(in : VSOut) -> @location(0) vec4f { let tileCoord = tmx.uVisibleStart + in.vRegion * tmx.uVisibleSize; let geomCell = floor(tileCoord); let worldPx = tileCoord * tmx.uCellSize; + let dWorldX = dpdx(worldPx); + let dWorldY = dpdy(worldPx); let gx = i32(geomCell.x); let gy = i32(geomCell.y); @@ -163,11 +204,11 @@ fn fragment_main(in : VSOut) -> @location(0) vec4f { // fast path: tiles fit the cell exactly — only the geometric cell can // contain this fragment's tile if (overflowX == 0 && overflowY == 0) { - result = tryRenderCell(gx, gy, worldPx); + result = tryRenderCell(gx, gy, worldPx, dWorldX, dWorldY); if (!result.hit) { discard; } - return vec4f(result.color.rgb, result.color.a * tmx.uOpacity) * tmx.uTint; + return layerColor(result.color); } // slow path: oversized tiles. Scan candidates dy high→low, dx low→high @@ -183,7 +224,7 @@ fn fragment_main(in : VSOut) -> @location(0) vec4f { if (dx > overflowX) { break; } - let candidate = tryRenderCell(gx - dx, gy + dy, worldPx); + let candidate = tryRenderCell(gx - dx, gy + dy, worldPx, dWorldX, dWorldY); if (candidate.hit) { result = candidate; found = true; @@ -197,5 +238,5 @@ fn fragment_main(in : VSOut) -> @location(0) vec4f { if (!found) { discard; } - return vec4f(result.color.rgb, result.color.a * tmx.uOpacity) * tmx.uTint; + return layerColor(result.color); } diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index 3523b85f00..d8a694aa90 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -22,6 +22,7 @@ import AmbientLightEffect from "../effects/ambientlight.js"; import BlendEffect from "../effects/blendEffect.js"; import { meshHostingBlocker } from "../effects/mesh_splice.js"; import RadialGradientEffect from "../effects/radialGradient.js"; +import { frameClampMode } from "../gpu/frame.js"; import { Gradient } from "../gradient.js"; import Renderer from "../renderer.js"; import RenderTargetPool from "../rendertarget/render_target_pool.js"; @@ -2434,6 +2435,44 @@ export default class WebGPURenderer extends Renderer { super.drawTileLayer(layer, rect); } + /** + * A tile layer drawn tile by tile: the sprite shaders keep each tile's + * samples half a texel inside it (see `gpu/frame.js`), so two tiles + * never blend the next tile of their tileset in along their edge. + * @ignore + * @internal + */ + beginTileLayer() { + this.setDrawingTiles(true); + } + + /** + * @ignore + * @internal + */ + endTileLayer() { + this.setDrawingTiles(false); + } + + /** + * Flip `drawingTiles`, and when that changes the sprite shaders' frame + * clamp, draw the quads queued under the old one and push a frame-globals + * slot carrying the new one (the shaders read it from there). + * @param {boolean} on - whether a tile layer is drawing + * @ignore + * @internal + */ + setDrawingTiles(on) { + const before = frameClampMode(this); + this.drawingTiles = on; + // pushed even when no pass is open, which happens mid-frame too (a + // post effect, a mask, a flush): the next draws read it + if (frameClampMode(this) !== before && this.uniformRing !== null) { + this.currentBatcher?.flush(); + this.pushFrameGlobals(); + } + } + /** * Add a batcher to this renderer. * @param {WebGPUBatcher} batcher - a batcher instance @@ -2996,6 +3035,7 @@ export default class WebGPURenderer extends Renderer { this.currentFrameBinding = this.uniformRing.pushFrameGlobals( this.projectionMatrix, this.lineWidth, + frameClampMode(this), ); this.currentFrameLineWidth = this.lineWidth; } @@ -4458,7 +4498,12 @@ export default class WebGPURenderer extends Renderer { /** * Set the default texture filter at runtime, decoupled from * {@link WebGPURenderer#setAntiAlias} — same re-pairing mechanism. - * @param {"auto"|"nearest"|"linear"} [mode="auto"] - `"auto"` follows `antiAlias` + * @param {"auto"|"nearest"|"linear"|"linear-mipmap"} [mode="auto"] - `"auto"` follows `antiAlias` + * @example + * // smooth, and from the mipmaps wherever something is drawn small + * app.renderer.setTextureFilter("linear-mipmap"); + * // back to the antiAlias setting + * app.renderer.setTextureFilter("auto"); */ setTextureFilter(mode = "auto") { super.setTextureFilter(mode); @@ -4469,6 +4514,13 @@ export default class WebGPURenderer extends Renderer { // each embeds samplers resolved from the default filter this.batchers.get("litQuad")?.clearMaterialCache(); this.batchers.get("quad")?.clearMaterialCache(); + // the sprite shaders read the frame clamp mode from the frame + // globals (under "linear-mipmap" a sprite keeps its samples inside + // its frame): a fresh slot carries it, pushed even when no pass is + // open, which happens mid-frame too (a post effect, a mask, a flush) + if (this.uniformRing !== null) { + this.pushFrameGlobals(); + } } /** diff --git a/packages/melonjs/tests/camera-cull-mask.spec.js b/packages/melonjs/tests/camera-cull-mask.spec.js new file mode 100644 index 0000000000..09061b4339 --- /dev/null +++ b/packages/melonjs/tests/camera-cull-mask.spec.js @@ -0,0 +1,518 @@ +/** + * `Renderable#visibilityLayer` and `Camera2d#cullMask`: a camera draws a + * renderable only if they share a bit. + * + * Driven through `Container#draw`, the one place a camera walks the world, + * counting which children get drawn. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + Camera2d, + Camera3d, + Container, + Light2d, + ParticleEmitter, + Renderable, + Stage, + state, + TMXTileMap, + video, +} from "../src/index.js"; +import { resetRenderableState } from "../src/renderable/renderable.js"; + +describe("visibilityLayer and cullMask", () => { + let app; + let renderer; + + beforeAll(async () => { + app = new Application(100, 100, { renderer: video.CANVAS }); + await app.init(); + renderer = app.renderer; + }); + + afterAll(() => { + app?.destroy(); + }); + + // a renderable that counts its draws, on `layer` when given + const counted = (layer) => { + const r = new Renderable(0, 0, 10, 10); + if (layer !== undefined) { + r.visibilityLayer = layer; + } + r.draws = 0; + r.draw = () => { + r.draws++; + }; + return r; + }; + + // a container of `children`, all in view, drawn once by `camera` + const drawWith = (camera, ...children) => { + const world = new Container(0, 0, 100, 100); + for (const child of children) { + world.addChild(child); + child.inViewport = true; + } + renderer.save(); + world.draw(renderer, camera); + renderer.restore(); + return world; + }; + + // a camera drawn in a corner of the screen, as a minimap is + const corner = () => { + const camera = new Camera2d(0, 0, 50, 50); + camera.screenX = 50; + return camera; + }; + + describe("by default", () => { + it("draws every layer in a container drawn with no camera", () => { + // code that calls draw(renderer) itself, as a custom container + // drawing its children with super.draw(renderer) + const children = [counted(1), counted(2), counted(1 << 30)]; + drawWith(undefined, ...children); + for (const child of children) { + expect(child.draws).toBe(1); + } + }); + + it("puts every renderable on layer 1", () => { + expect(new Renderable(0, 0, 1, 1).visibilityLayer).toBe(1); + expect(new Container(0, 0, 1, 1).visibilityLayer).toBe(1); + }); + + it("lets every camera draw every layer", () => { + expect(new Camera2d(0, 0, 10, 10).cullMask).toBe(-1); + expect(new Camera3d(0, 0, 10, 10).cullMask).toBe(-1); + }); + + it("draws everything, whatever its layer", () => { + const children = [counted(), counted(2), counted(1 << 30)]; + drawWith(app.viewport, ...children); + drawWith(corner(), ...children); + for (const child of children) { + expect(child.draws).toBe(2); + } + }); + }); + + describe("a camera with a mask", () => { + it("leaves out a renderable on a layer it does not draw", () => { + const camera = corner(); + camera.cullMask = ~2; + const kept = counted(1); + const left = counted(2); + drawWith(camera, kept, left); + expect(kept.draws).toBe(1); + expect(left.draws).toBe(0); + }); + + it("draws a renderable on several layers if one of them is in the mask", () => { + const camera = corner(); + camera.cullMask = 4; + const both = counted(2 | 4); + drawWith(camera, both); + expect(both.draws).toBe(1); + }); + + it("leaves it out on the default camera too", () => { + app.viewport.cullMask = ~2; + try { + const left = counted(2); + drawWith(app.viewport, left); + expect(left.draws).toBe(0); + } finally { + app.viewport.cullMask = -1; + } + }); + + it("leaves out a container with everything inside it", () => { + const camera = corner(); + camera.cullMask = ~2; + const inside = counted(1); + const box = new Container(0, 0, 50, 50); + box.visibilityLayer = 2; + box.addChild(inside); + inside.inViewport = true; + drawWith(camera, box); + expect(inside.draws).toBe(0); + }); + + it("draws a container's contents only on the layers they share with it", () => { + // the container is drawn (layer 2), but its child is on layer 1 + const camera = corner(); + camera.cullMask = 2; + const inside = counted(1); + const box = new Container(0, 0, 50, 50); + box.visibilityLayer = 2; + box.addChild(inside); + inside.inViewport = true; + drawWith(camera, box); + expect(inside.draws).toBe(0); + }); + + it("leaves out a floating renderable drawn by every camera", () => { + // `visibleInAllCameras` lets a camera draw it; the mask still applies + const floating = counted(2); + floating.floating = true; + floating.visibleInAllCameras = true; + const camera = corner(); + drawWith(camera, floating); + expect(floating.draws).toBe(1); + camera.cullMask = ~2; + drawWith(camera, floating); + expect(floating.draws).toBe(1); + }); + + it("works the same on a Camera3d", () => { + const camera = new Camera3d(0, 0, 50, 50); + camera.screenX = 50; + camera.cullMask = ~2; + const kept = counted(1); + const left = counted(2); + drawWith(camera, kept, left); + expect(kept.draws).toBe(1); + expect(left.draws).toBe(0); + }); + }); + + describe("at the edges of a 32-bit mask", () => { + it("treats bit 31, the sign bit, as a layer like any other", () => { + const top = counted(1 << 31); + const camera = corner(); + drawWith(camera, top); + camera.cullMask = ~(1 << 31); + drawWith(camera, top); + camera.cullMask = 1 << 31; + drawWith(camera, top); + expect(top.draws).toBe(2); + }); + + it("draws a renderable on no layer in no camera, even one drawing every layer", () => { + const nowhere = counted(0); + drawWith(app.viewport, nowhere); + drawWith(corner(), nowhere); + expect(nowhere.draws).toBe(0); + }); + + it("draws nothing at all through an empty mask, floating or not", () => { + const camera = corner(); + camera.cullMask = 0; + const plain = counted(); + const floating = counted(); + floating.floating = true; + floating.visibleInAllCameras = true; + drawWith(camera, plain, floating); + expect(plain.draws + floating.draws).toBe(0); + }); + }); + + describe("from one draw to the next", () => { + it("lets two cameras draw their own layers of the same world in one frame", () => { + const ground = counted(1); + const sparks = counted(2); + const minimap = corner(); + minimap.cullMask = ~2; + const world = new Container(0, 0, 100, 100); + for (const child of [ground, sparks]) { + world.addChild(child); + child.inViewport = true; + } + for (const camera of [app.viewport, minimap, app.viewport]) { + renderer.save(); + world.draw(renderer, camera); + renderer.restore(); + } + expect(ground.draws).toBe(3); + expect(sparks.draws).toBe(2); + }); + + it("takes a change of layer or of mask on the very next draw", () => { + const camera = corner(); + camera.cullMask = ~2; + const r = counted(2); + drawWith(camera, r); + expect(r.draws).toBe(0); + r.visibilityLayer = 1; + drawWith(camera, r); + expect(r.draws).toBe(1); + camera.cullMask = 0; + drawWith(camera, r); + expect(r.draws).toBe(1); + }); + + it("leaves the renderer's transform as it found it, around what it leaves out", () => { + // a left out child is skipped before its preDraw, so nothing is + // pushed that its postDraw would have to pop + const camera = corner(); + camera.cullMask = ~2; + const moved = (layer) => { + const r = counted(layer); + r.pos.set(13, 7, 0); + r.anchorPoint.set(0.5, 0.5); + r.flipX(true); + return r; + }; + const before = renderer.getContext().getTransform().toString(); + drawWith(camera, moved(2), moved(1), moved(2), moved(1)); + expect(renderer.getContext().getTransform().toString()).toBe(before); + }); + }); + + describe("down a tree", () => { + it("leaves out every descendant of a container left out, however deep", () => { + const camera = corner(); + camera.cullMask = ~2; + const leaf = counted(1); + const inner = new Container(0, 0, 50, 50); + inner.addChild(leaf); + const middle = new Container(0, 0, 50, 50); + middle.visibilityLayer = 2; + middle.addChild(inner); + const outer = new Container(0, 0, 50, 50); + outer.addChild(middle); + for (const node of [leaf, inner, middle, outer]) { + node.inViewport = true; + } + drawWith(camera, outer); + expect(leaf.draws).toBe(0); + middle.visibilityLayer = 1; + drawWith(camera, outer); + expect(leaf.draws).toBe(1); + }); + + it("draws none of a particle emitter's particles when it is left out", () => { + const emitter = new ParticleEmitter(0, 0, { + width: 10, + height: 10, + totalParticles: 6, + }); + emitter.burstParticles(); + let draws = 0; + for (const particle of emitter.getChildren()) { + particle.inViewport = true; + particle.draw = () => { + draws++; + }; + } + expect(emitter.getChildren().length).toBeGreaterThan(0); + const camera = corner(); + drawWith(camera, emitter); + const shown = draws; + expect(shown).toBeGreaterThan(0); + emitter.visibilityLayer = 2; + camera.cullMask = ~2; + drawWith(camera, emitter); + expect(draws).toBe(shown); + emitter.destroy(); + }); + + it("draws nothing of a world on a layer the camera leaves out", () => { + const camera = corner(); + const world = new Container(0, 0, 100, 100); + const child = counted(1); + world.addChild(child); + child.inViewport = true; + camera.draw(renderer, world); + expect(child.draws).toBe(1); + world.visibilityLayer = 2; + camera.cullMask = ~2; + camera.draw(renderer, world); + expect(child.draws).toBe(1); + }); + }); + + describe("lights", () => { + // a stage lit by two lights, one on layer 2 + const litStage = () => { + const stage = new Stage(); + stage.ambientLight.setColor(0, 0, 0, 0.8); + const kept = new Light2d(10, 10, 20, 20); + const left = new Light2d(60, 10, 20, 20); + left.visibilityLayer = 2; + stage._activeLights.add(kept); + stage._activeLights.add(left); + return { stage, kept, left }; + }; + + // the lights cut into a camera's darkness, through a recording renderer + const cut = (stage, camera) => { + const holes = []; + const recorder = { + save() {}, + restore() {}, + translate() {}, + setColor() {}, + fillRect() {}, + clearMask() {}, + setMask(shape) { + holes.push(shape); + }, + drawAmbientLight(color, lights) { + holes.push(...lights); + }, + }; + stage.drawLighting(recorder, camera, 0, 0); + return holes; + }; + + it("cuts no hole in a camera's darkness for a light it leaves out", () => { + const { stage, kept } = litStage(); + const camera = corner(); + expect(cut(stage, camera).length).toBe(2); + camera.cullMask = ~2; + const holes = cut(stage, camera); + expect(holes.length).toBe(1); + expect(holes[0]).toBe(kept.getVisibleArea()); + }); + + it("leaves it out of the soft darkness too", () => { + const { stage, kept, left } = litStage(); + kept.softness = 1; + left.softness = 1; + const camera = corner(); + camera.cullMask = ~2; + expect(cut(stage, camera)).toEqual([kept]); + }); + + it("does not light normal-mapped sprites with it in that camera", () => { + const { stage, kept } = litStage(); + const current = state.current(); + const saved = new Set(current._activeLights); + for (const light of stage._activeLights) { + current._activeLights.add(light); + } + const given = []; + const setLightUniforms = renderer.setLightUniforms; + renderer.setLightUniforms = (lights) => { + given.push(...lights); + }; + try { + const camera = corner(); + camera.cullMask = ~2; + camera.draw(renderer, new Container(0, 0, 10, 10)); + expect(given).toEqual([kept]); + } finally { + renderer.setLightUniforms = setLightUniforms; + current._activeLights.clear(); + for (const light of saved) { + current._activeLights.add(light); + } + } + }); + + it("leaves out a light inside a container it leaves out, however deep", () => { + // a torch carried by a player the minimap leaves out: it draws + // nothing there, and cuts no hole in its darkness either + const { stage, kept } = litStage(); + const player = new Container(0, 0, 50, 50); + const hand = new Container(0, 0, 10, 10); + const torch = new Light2d(20, 20, 20, 20); + player.addChild(hand); + hand.addChild(torch); + stage._activeLights.add(torch); + player.visibilityLayer = 4; + const minimap = corner(); + minimap.cullMask = ~4; + expect(stage._lightsFor(minimap)).not.toContain(torch); + expect(cut(stage, minimap)).not.toContain(torch); + // the main camera draws it + const main = corner(); + expect(stage._lightsFor(main)).toContain(torch); + expect(stage._lightsFor(main)).toContain(kept); + }); + + it("leaves out a light on no layer, even from a camera drawing every layer", () => { + const { stage, kept, left } = litStage(); + left.visibilityLayer = 0; + expect(stage._lightsFor(corner())).toEqual([kept]); + }); + }); + + describe("it only decides drawing", () => { + it("still updates what a camera leaves out", () => { + app.viewport.cullMask = ~2; + try { + const left = counted(2); + left.updates = 0; + left.update = () => { + left.updates++; + return false; + }; + left.alwaysUpdate = true; + const world = drawWith(app.viewport, left); + world.update(16); + expect(left.draws).toBe(0); + expect(left.updates).toBe(1); + } finally { + app.viewport.cullMask = -1; + } + }); + }); + + describe("elsewhere", () => { + it("puts a pooled renderable back on layer 1", () => { + const r = new Renderable(0, 0, 1, 1); + r.visibilityLayer = 8; + resetRenderableState(r); + expect(r.visibilityLayer).toBe(1); + }); + + it("is read from an object's custom property in a Tiled map", () => { + const map = new TMXTileMap("layers", { + width: 2, + height: 2, + tilewidth: 32, + tileheight: 32, + orientation: "orthogonal", + renderorder: "right-down", + infinite: false, + version: "1.10", + tiledversion: "1.12.0", + tilesets: [], + layers: [ + { + type: "objectgroup", + name: "things", + opacity: 1, + visible: true, + objects: [ + { + id: 1, + name: "particles", + type: "", + x: 0, + y: 0, + width: 32, + height: 32, + properties: [ + { name: "visibilityLayer", type: "int", value: 2 }, + ], + }, + { + id: 2, + name: "plain", + type: "", + x: 32, + y: 0, + width: 32, + height: 32, + }, + ], + }, + ], + }); + const objects = map.getObjects(true); + const byName = (name) => { + return objects.find((o) => { + return o.name === name; + }); + }; + expect(byName("particles").visibilityLayer).toBe(2); + expect(byName("plain").visibilityLayer).toBe(1); + }); + }); +}); diff --git a/packages/melonjs/tests/camera.spec.js b/packages/melonjs/tests/camera.spec.js index 3c1a3d03c1..f63dd88466 100644 --- a/packages/melonjs/tests/camera.spec.js +++ b/packages/melonjs/tests/camera.spec.js @@ -460,6 +460,49 @@ describe("Camera2d", () => { // deadzone y = ~~((600 - 100) / 2 - 100 * 0.25) = ~~(250 - 25) = 225 expect(camera.deadzone.pos.y).toEqual(225); }); + + it("keeps every edge on a whole pixel", () => { + const { camera } = setup(); + // the default deadzone is a sixth of the view: 213.33 x 121.67 + camera.resize(1280, 730); + const zone = camera.deadzone; + for (const edge of [zone.left, zone.right, zone.top, zone.bottom]) { + expect(Number.isInteger(edge)).toBe(true); + } + // the nearest whole size to the one asked for + camera.setDeadzone(213.6, 99.4); + expect(camera.deadzone.width).toEqual(214); + expect(camera.deadzone.height).toEqual(99); + // rounded either way, as the nearest is + camera.setDeadzone(213.4, 99.6); + expect(camera.deadzone.width).toEqual(213); + expect(camera.deadzone.height).toEqual(100); + }); + + it("brings a camera following a whole-pixel target to rest on a whole pixel", () => { + const { camera } = setup(); + camera.resize(1280, 730); + camera.setBounds(0, 0, 100000, 100000); + camera.reset(20000, 20000); + const target = new Vector2d(20640, 20365); + camera.follow(target, camera.AXIS.BOTH); + // right and left, then down and up: the camera is pushed by the + // right and bottom edges as much as by the left and top ones + for (const [dx, dy] of [ + [7, 0], + [-7, 0], + [0, 7], + [0, -7], + ]) { + for (let i = 0; i < 100; i++) { + target.x += dx; + target.y += dy; + camera.update(16); + expect(Number.isInteger(camera.pos.x)).toBe(true); + expect(Number.isInteger(camera.pos.y)).toBe(true); + } + } + }); }); describe("focusOn()", () => { diff --git a/packages/melonjs/tests/canvas-resize-state.spec.js b/packages/melonjs/tests/canvas-resize-state.spec.js new file mode 100644 index 0000000000..6525b3ba31 --- /dev/null +++ b/packages/melonjs/tests/canvas-resize-state.spec.js @@ -0,0 +1,126 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { boot, Color } from "../src/index.js"; +import CanvasRenderer from "../src/video/canvas/canvas_renderer.js"; + +/** + * Resizing a canvas resets its 2D context to the defaults. The Canvas + * renderer keeps its drawing state in that context, so a resize used to + * drop it: image smoothing came back on, which drew an `antiAlias: false` + * game smoothed, with seams where smoothing took in a tileset's spacing. + */ +describe("CanvasRenderer: a resize keeps the drawing state", () => { + beforeAll(async () => { + await boot(); + }); + + /** + * a 2x1 source: one black texel, one white + * @returns {HTMLCanvasElement} the source + */ + function blackWhite() { + const source = document.createElement("canvas"); + source.width = 2; + source.height = 1; + const context = source.getContext("2d"); + context.fillStyle = "#000"; + context.fillRect(0, 0, 1, 1); + context.fillStyle = "#fff"; + context.fillRect(1, 0, 1, 1); + return source; + } + + /** + * the red channel along the first row after drawing the 2x1 source + * stretched to 16 pixels wide + * @param {CanvasRenderer} renderer - the renderer to draw with + * @returns {number[]} 16 red values + */ + function stretched(renderer) { + renderer.drawImage(blackWhite(), 0, 0, 2, 1, 0, 0, 16, 1); + const data = renderer.getContext().getImageData(0, 0, 16, 1).data; + const row = []; + for (let x = 0; x < 16; x++) { + row.push(data[x * 4]); + } + return row; + } + + it("antiAlias: false still draws unsmoothed after a resize", () => { + const renderer = new CanvasRenderer({ + width: 8, + height: 8, + antiAlias: false, + }); + renderer.resize(32, 32); + expect(renderer.getContext().imageSmoothingEnabled).toBe(false); + // two flat halves, nothing in between + const row = stretched(renderer); + expect( + row.slice(0, 8).every((red) => { + return red === 0; + }), + ).toBe(true); + expect( + row.slice(8).every((red) => { + return red === 255; + }), + ).toBe(true); + }); + + it("CONTROL: the same draw smoothed blends the two texels", () => { + const renderer = new CanvasRenderer({ + width: 8, + height: 8, + antiAlias: true, + }); + renderer.resize(32, 32); + expect(renderer.getContext().imageSmoothingEnabled).toBe(true); + const row = stretched(renderer); + expect( + row.some((red) => { + return red > 0 && red < 255; + }), + ).toBe(true); + }); + + it("keeps the blend mode, color and line width, join and dash", () => { + const renderer = new CanvasRenderer({ width: 8, height: 8 }); + renderer.setBlendMode("multiply"); + renderer.setColor(new Color(255, 0, 0, 1)); + renderer.lineWidth = 5; + renderer.lineJoin = "bevel"; + renderer.setLineDash([4, 2]); + renderer.resize(24, 16); + const context = renderer.getContext(); + expect(context.globalCompositeOperation).toBe("multiply"); + expect(renderer.currentBlendMode).toBe("multiply"); + expect(context.fillStyle).toBe("#ff0000"); + expect(context.strokeStyle).toBe("#ff0000"); + expect(renderer.lineWidth).toBe(5); + expect(renderer.lineJoin).toBe("bevel"); + expect(context.getLineDash()).toEqual([4, 2]); + }); + + it("keeps a gradient set as the color", () => { + const renderer = new CanvasRenderer({ width: 8, height: 8 }); + const gradient = renderer.createLinearGradient(0, 0, 8, 0); + gradient.addColorStop(0, "#ff0000"); + gradient.addColorStop(1, "#0000ff"); + renderer.setColor(gradient); + renderer.resize(16, 16); + expect(renderer.renderState.currentGradient).toBe(gradient); + expect(renderer.getContext().fillStyle).toBeInstanceOf(CanvasGradient); + }); + + it("leaves the context alone when the size does not change", () => { + const renderer = new CanvasRenderer({ width: 8, height: 8 }); + const canvas = renderer.getCanvas(); + renderer.resize(canvas.width, canvas.height); + // a context nobody reset keeps what was drawn on it + renderer.setColor("#00ff00"); + renderer.fillRect(0, 0, 8, 8); + renderer.resize(canvas.width, canvas.height); + const data = renderer.getContext().getImageData(4, 4, 1, 1).data; + expect(Array.from(data)).toEqual([0, 255, 0, 255]); + }); +}); diff --git a/packages/melonjs/tests/depth.spec.js b/packages/melonjs/tests/depth.spec.js index 0638ec91af..25c3ca808b 100644 --- a/packages/melonjs/tests/depth.spec.js +++ b/packages/melonjs/tests/depth.spec.js @@ -265,8 +265,8 @@ describe("WebGL batchers carry depth as vec3 aVertex (PR A)", () => { }; // QuadBatcher layout: aVertex(3) + aRegion(2) + aColor(4 UBYTE = 1 float-slot) - // + aTextureId(1) = 7 float-slots * 4 bytes = 28 bytes - it("QuadBatcher declares aVertex size 3, stride 28", (ctx) => { + // + aTextureId(1) + aFrame(2) = 9 float-slots * 4 bytes = 36 bytes + it("QuadBatcher declares aVertex size 3, stride 36", (ctx) => { if (skipIfNoWebGL(ctx)) { return; } @@ -276,12 +276,12 @@ describe("WebGL batchers carry depth as vec3 aVertex (PR A)", () => { }); expect(aVertex).toBeDefined(); expect(aVertex.size).toBe(3); - expect(batcher.stride).toBe(28); + expect(batcher.stride).toBe(36); }); - // LitQuadBatcher adds aNormalTextureId and aShininess at the tail - // → 9 float-slots = 36 bytes - it("LitQuadBatcher declares aVertex size 3, stride 36", (ctx) => { + // LitQuadBatcher adds aNormalTextureId and aShininess, then aFrame at + // the tail → 11 float-slots = 44 bytes + it("LitQuadBatcher declares aVertex size 3, stride 44", (ctx) => { if (skipIfNoWebGL(ctx)) { return; } @@ -291,7 +291,7 @@ describe("WebGL batchers carry depth as vec3 aVertex (PR A)", () => { }); expect(aVertex).toBeDefined(); expect(aVertex.size).toBe(3); - expect(batcher.stride).toBe(36); + expect(batcher.stride).toBe(44); }); // PrimitiveBatcher: aVertex(3) + aNormal(2) + aColor(4 UBYTE = 1 float-slot) diff --git a/packages/melonjs/tests/depth_adversarial.spec.js b/packages/melonjs/tests/depth_adversarial.spec.js index a8893691a5..0aeb2f0c01 100644 --- a/packages/melonjs/tests/depth_adversarial.spec.js +++ b/packages/melonjs/tests/depth_adversarial.spec.js @@ -278,8 +278,8 @@ describe("depth pipeline adversarial integration", () => { // intercept vertex pushes so we can inspect z values before the // internal flush wipes them const captured = []; - const originalPush = batcher.vertexData.push.bind(batcher.vertexData); - batcher.vertexData.push = (...args) => { + const originalPush = batcher.vertexData.pushSprite.bind(batcher.vertexData); + batcher.vertexData.pushSprite = (...args) => { captured.push(args[2]); // z is the 3rd component (x, y, z, ...) originalPush(...args); }; @@ -287,7 +287,7 @@ describe("depth pipeline adversarial integration", () => { try { batcher.blitTexture(rawTex, 0, 0, 32, 32, fx); } finally { - batcher.vertexData.push = originalPush; + batcher.vertexData.pushSprite = originalPush; fx.destroy(); } diff --git a/packages/melonjs/tests/effect-batching.spec.js b/packages/melonjs/tests/effect-batching.spec.js new file mode 100644 index 0000000000..19f19e8966 --- /dev/null +++ b/packages/melonjs/tests/effect-batching.spec.js @@ -0,0 +1,590 @@ +/** + * Sprites drawn through the same ShaderEffect share one draw. + * + * An effect sprite used to be a draw of its own: the renderer put the + * default program back after every one, and the frame an effect keeps its + * samples inside was a uniform written per sprite. The frame now comes with + * the quad, the effect stays bound until a sprite without it, and a uniform + * that takes a new value draws the quads queued under the old one first. + * So sprites sharing an effect share a draw, and each still draws with the + * values it was given. + * + * Measured on WebGL in draw calls and in pixels (every batched scene is + * compared with the same scene drawn one sprite per draw), and on WebGPU + * against the mock renderer. + */ +import "./helpers/webgpu-globals.js"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { boot, GlowEffect, OutlineEffect, ShaderEffect } from "../src/index.js"; +import Renderer from "../src/video/renderer.js"; +import WebGPUQuadBatcher from "../src/video/webgpu/batchers/quad_batcher.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +const SIZE = 96; +const FRAME = 16; + +// a tint the sprite is multiplied by +const TINT_BODY = ` +uniform vec3 uTint; +vec4 apply(vec4 color, vec2 uv) { + return vec4(color.rgb * uTint, color.a); +} +`; + +// the frame-local coordinate, as a colour +const NOISE_BODY = ` +vec4 apply(vec4 color, vec2 uv) { + return vec4(noise_uv, 0.0, 1.0) * color.a; +} +`; + +describe("effect sprites share a draw (WebGL)", () => { + let renderer; + let atlas; + let drawCalls; + + beforeAll(async () => { + await boot(); + try { + renderer = await getWebGLRenderer(SIZE, SIZE); + } catch { + // no WebGL here: the tests below mark themselves skipped + } + if (renderer) { + renderer.projectionMatrix.ortho(0, SIZE, SIZE, 0, -1000, 1000); + renderer.currentBatcher.setProjection(renderer.projectionMatrix); + const gl = renderer.gl; + const drawElements = gl.drawElements.bind(gl); + drawCalls = 0; + gl.drawElements = (...args) => { + drawCalls++; + return drawElements(...args); + }; + } + // four frames: white, a red square on transparent, a green one, and + // opaque blue. Frames 1 and 2 are drawn; their neighbours are opaque + atlas = Renderer.createCanvas(FRAME * 4, FRAME); + const ctx = atlas.getContext("2d"); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, FRAME, FRAME); + ctx.fillStyle = "#ff0000"; + ctx.fillRect(FRAME + 4, 4, 8, 8); + ctx.fillStyle = "#00ff00"; + ctx.fillRect(FRAME * 2 + 4, 4, 8, 8); + ctx.fillStyle = "#0000ff"; + ctx.fillRect(FRAME * 3, 0, FRAME, FRAME); + }); + + afterEach(() => { + if (renderer) { + renderer.customShader = undefined; + renderer.flush(); + } + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + const snapshot = () => { + const gl = renderer.gl; + const out = new Uint8Array(SIZE * SIZE * 4); + gl.readPixels(0, 0, SIZE, SIZE, gl.RGBA, gl.UNSIGNED_BYTE, out); + return out; + }; + + // where two snapshots differ by more than `tolerance`, as "x,y" + const differences = (a, b, tolerance = 0) => { + const out = []; + for (let i = 0; i < a.length; i += 4) { + for (let c = 0; c < 4; c++) { + if (Math.abs(a[i + c] - b[i + c]) > tolerance) { + const p = i / 4; + out.push(`${p % SIZE},${SIZE - 1 - Math.floor(p / SIZE)}`); + break; + } + } + } + return out; + }; + + /** + * Draw `sprites` ([frame, x, y, before?]) through `effect`, where + * `before(effect)` runs ahead of that sprite (a uniform change). With + * `alone`, each sprite is drawn before the next one is queued: the + * scene as it was before sprites shared a draw. + * @returns {{draws: number, pixels: Uint8Array}} + */ + const scene = (effect, sprites, alone = false, size = FRAME) => { + renderer.clearColor("#000000ff"); + renderer.flush(); + const start = drawCalls; + for (const [frame, x, y, before] of sprites) { + before?.(effect); + renderer.customShader = effect; + renderer.drawImage( + atlas, + frame * FRAME, + 0, + FRAME, + FRAME, + x, + y, + size, + size, + ); + renderer.customShader = undefined; + if (alone) { + renderer.flush(); + } + } + renderer.flush(); + return { draws: drawCalls - start, pixels: snapshot() }; + }; + + const ROW = [ + [1, 4, 4], + [2, 28, 4], + [1, 52, 4], + [2, 76, 4], + [1, 4, 40], + [2, 28, 40], + ]; + + it("sprites sharing an effect draw in one call", (ctx) => { + requireWebGL(ctx); + const effect = new ShaderEffect(renderer, TINT_BODY); + effect.setUniform("uTint", [1, 0.5, 1]); + const together = scene(effect, ROW); + const alone = scene(effect, ROW, true); + effect.destroy(); + expect(together.draws).toBe(1); + expect(alone.draws).toBe(ROW.length); + expect(differences(together.pixels, alone.pixels)).toEqual([]); + }); + + it("setting a uniform to the value it has keeps the draw shared", (ctx) => { + requireWebGL(ctx); + const effect = new ShaderEffect(renderer, TINT_BODY); + const same = (fx) => { + fx.setUniform("uTint", new Float32Array([1, 0.5, 1])); + }; + const { draws } = scene( + effect, + ROW.map(([f, x, y]) => { + return [f, x, y, same]; + }), + ); + effect.destroy(); + expect(draws).toBe(1); + }); + + it("a sprite given its own uniform value draws with it", (ctx) => { + requireWebGL(ctx); + // red, then dimmed, then full again: three runs of one value each + const tint = (r) => { + return (fx) => { + fx.setUniform("uTint", [r, 1, 1]); + }; + }; + const sprites = [ + [1, 4, 4, tint(1)], + [1, 28, 4, tint(0.25)], + [1, 52, 4], + [1, 76, 4, tint(1)], + ]; + const effect = new ShaderEffect(renderer, TINT_BODY); + const together = scene(effect, sprites); + const alone = scene(effect, sprites, true); + effect.destroy(); + expect(together.draws).toBe(3); + expect(differences(together.pixels, alone.pixels)).toEqual([]); + // and the values did land where they were set: (8,8) and (32,8) + const red = (x, y) => { + return together.pixels[((SIZE - 1 - y) * SIZE + x) * 4]; + }; + expect(red(8, 8)).toBeGreaterThan(240); + expect(red(32, 8)).toBeLessThan(80); + expect(red(56, 8)).toBeLessThan(80); + expect(red(80, 8)).toBeGreaterThan(240); + }); + + it.for([ + [ + "an outline", + () => { + return new OutlineEffect(renderer, { + color: [1, 1, 1], + width: 2, + textureSize: [FRAME * 4, FRAME], + }); + }, + ], + [ + "a glow", + () => { + return new GlowEffect(renderer, { + color: [1, 1, 0], + width: 3, + textureSize: [FRAME * 4, FRAME], + }); + }, + ], + ])( + "%s on sprites from different frames: one draw, each kept to its own frame", + ([, make], ctx) => { + // the frame comes with each quad, so sprites of different frames + // share the draw, and none reads its neighbour's opaque edge + requireWebGL(ctx); + const effect = make(); + const together = scene(effect, ROW); + const alone = scene(effect, ROW, true); + effect.destroy(); + expect(together.draws).toBe(1); + expect(differences(together.pixels, alone.pixels)).toEqual([]); + }, + ); + + it("the frame effect's program reads the frame where every sprite layout has it", (ctx) => { + requireWebGL(ctx); + const effect = new OutlineEffect(renderer, { + textureSize: [FRAME * 4, FRAME], + }); + expect(effect._shader.getAttribLocation("aTextureId")).toBe(3); + expect(effect._shader.getAttribLocation("aFrame")).toBe(4); + // and the frame is no longer a live uniform of the sprite program + // (the declaration a mesh reads is still in the source, inactive) + expect(effect._shader.uniforms.ME_frame ?? null).toBe(null); + expect(effect._shader.uniforms.ME_frame_half).toBeDefined(); + effect.destroy(); + }); + + it("a sprite without the effect, between two with it, draws without it", (ctx) => { + requireWebGL(ctx); + const effect = new ShaderEffect(renderer, TINT_BODY); + effect.setUniform("uTint", [0, 0, 0]); + renderer.clearColor("#000000ff"); + renderer.customShader = effect; + renderer.drawImage(atlas, FRAME, 0, FRAME, FRAME, 4, 4, FRAME, FRAME); + renderer.customShader = undefined; + renderer.drawImage(atlas, FRAME, 0, FRAME, FRAME, 28, 4, FRAME, FRAME); + renderer.customShader = effect; + renderer.drawImage(atlas, FRAME, 0, FRAME, FRAME, 52, 4, FRAME, FRAME); + renderer.customShader = undefined; + renderer.flush(); + const pixels = snapshot(); + effect.destroy(); + const red = (x, y) => { + return pixels[((SIZE - 1 - y) * SIZE + x) * 4]; + }; + // tinted black, plain red, tinted black + expect(red(8, 8)).toBe(0); + expect(red(32, 8)).toBeGreaterThan(240); + expect(red(56, 8)).toBe(0); + }); + + describe("a texture set between two sprites", () => { + // the effect draws the color of its mask texture + const MASK_BODY = ` + uniform sampler2D uMask; + vec4 apply(vec4 color, vec2 uv) { + return vec4(texture2D(uMask, vec2(0.5)).rgb, 1.0); + }`; + const solid = (color) => { + const canvas = Renderer.createCanvas(4, 4); + const ctx2d = canvas.getContext("2d"); + ctx2d.fillStyle = color; + ctx2d.fillRect(0, 0, 4, 4); + return canvas; + }; + const at = (pixels, x, y) => { + const i = ((SIZE - 1 - y) * SIZE + x) * 4; + return [pixels[i], pixels[i + 1], pixels[i + 2]]; + }; + + it("draws each sprite with its own", (ctx) => { + requireWebGL(ctx); + const effect = new ShaderEffect(renderer, MASK_BODY); + const red = solid("#ff0000"); + const green = solid("#00ff00"); + const { pixels } = scene(effect, [ + [ + 1, + 4, + 4, + (fx) => { + return fx.setTexture("uMask", red); + }, + ], + [ + 2, + 28, + 4, + (fx) => { + return fx.setTexture("uMask", green); + }, + ], + ]); + expect(at(pixels, 12, 12)).toEqual([255, 0, 0]); + expect(at(pixels, 36, 12)).toEqual([0, 255, 0]); + effect.destroy(); + }); + + it("leaves no deleted texture for a filter change to bind", (ctx) => { + // the replaced texture, and those of a destroyed effect, are + // deleted: binding one again is a GL error + requireWebGL(ctx); + const gl = renderer.gl; + const effect = new ShaderEffect(renderer, MASK_BODY); + scene(effect, [ + [ + 1, + 4, + 4, + (fx) => { + return fx.setTexture("uMask", solid("#f00")); + }, + ], + ]); + scene(effect, [ + [ + 1, + 4, + 4, + (fx) => { + return fx.setTexture("uMask", solid("#0f0")); + }, + ], + ]); + effect.destroy(); + while (gl.getError() !== gl.NO_ERROR) { + // drop what earlier tests left + } + const filter = renderer.settings.textureFilter; + renderer.setTextureFilter("linear"); + renderer.setTextureFilter(filter); + expect(gl.getError()).toBe(gl.NO_ERROR); + }); + }); + + it("noise_uv sprites drawn at different sizes each get their own rect", (ctx) => { + // the rect is a per-sprite uniform: a new one draws the queued + // sprites first, so each is drawn with its own + requireWebGL(ctx); + const sprites = [ + [1, 4, 4], + [2, 28, 4], + ]; + const effect = new ShaderEffect(renderer, NOISE_BODY); + const draw = (alone) => { + renderer.clearColor("#000000ff"); + renderer.flush(); + const start = drawCalls; + let size = FRAME; + for (const [frame, x, y] of sprites) { + renderer.customShader = effect; + renderer.drawImage( + atlas, + frame * FRAME, + 0, + FRAME, + FRAME, + x, + y, + size, + size, + ); + renderer.customShader = undefined; + size += 8; + if (alone) { + renderer.flush(); + } + } + renderer.flush(); + return { draws: drawCalls - start, pixels: snapshot() }; + }; + const together = draw(false); + const alone = draw(true); + effect.destroy(); + expect(together.draws).toBe(2); + expect(differences(together.pixels, alone.pixels)).toEqual([]); + }); + + it("noise_uv sprites with the same rect share the draw", (ctx) => { + requireWebGL(ctx); + const effect = new ShaderEffect(renderer, NOISE_BODY); + const sprites = [ + [1, 4, 4], + [1, 28, 4], + [1, 52, 4], + ]; + const together = scene(effect, sprites); + const alone = scene(effect, sprites, true); + effect.destroy(); + expect(together.draws).toBe(1); + expect(differences(together.pixels, alone.pixels)).toEqual([]); + }); +}); + +describe("effect sprites share a draw (WebGPU, mock renderer)", () => { + let renderer; + let batcher; + const atlas = { + getTexture() { + return { width: 64, height: 16 }; + }, + }; + + beforeEach(() => { + renderer = createMockWebGPURenderer(); + batcher = new WebGPUQuadBatcher(renderer); + }); + + const quiet = (make) => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + return make(); + } finally { + warn.mockRestore(); + } + }; + + it("a frame effect on sprites from different frames: one draw", () => { + const effect = quiet(() => { + return new OutlineEffect( + { shaderLanguage: "wgsl" }, + { textureSize: [64, 16] }, + ); + }); + renderer.customShader = effect; + for (let i = 0; i < 4; i++) { + const u = (i % 4) * 0.25; + batcher.addQuad(atlas, i * 20, 0, 16, 16, u, 0, u + 0.25, 1, 0xffffffff); + } + expect(renderer.calls.drawIndexed).toEqual([]); + batcher.flush(); + expect(renderer.calls.drawIndexed).toEqual([24]); + }); + + it("a frame effect's module reads the frame from the quad", () => { + const effect = quiet(() => { + return new GlowEffect( + { shaderLanguage: "wgsl" }, + { textureSize: [64, 16] }, + ); + }); + const code = effect.wgslRealization.code; + expect(code).toContain("@location(4) aFrame : vec2f"); + expect(code).toContain("ME_frame_v = vec4f("); + // the frame is the box of the vertex's corner and the opposite one + expect(code).toContain( + "vec4f(min(aRegion, aFrame), max(aRegion, aFrame)),", + ); + // the body's reads now go to the quad's frame; the half texel stays + expect(code).not.toMatch(/\bfx\.ME_frame\b/); + expect(code).toMatch(/\bfx\.ME_frame_half\b/); + }); + + it("a new noise_uv rect draws the sprites queued under the old one", () => { + const effect = quiet(() => { + return new ShaderEffect( + { shaderLanguage: "wgsl" }, + { + wgsl: "fn apply(color : vec4f, uv : vec2f) -> vec4f { return vec4f(noise_uv, 0.0, 1.0) * color.a; }", + }, + ); + }); + renderer.customShader = effect; + batcher.addQuad(atlas, 0, 0, 16, 16, 0, 0, 0.25, 1, 0xffffffff); + batcher.addQuad(atlas, 20, 0, 16, 16, 0, 0, 0.25, 1, 0xffffffff); + // the same rect: still queued + expect(renderer.calls.drawIndexed).toEqual([]); + // another frame: the two queued draw first + batcher.addQuad(atlas, 40, 0, 16, 16, 0.25, 0, 0.5, 1, 0xffffffff); + expect(renderer.calls.drawIndexed).toEqual([12]); + }); + + it("a texture set between two sprites draws the sprites queued with the old one", () => { + const effect = quiet(() => { + return new ShaderEffect( + { shaderLanguage: "wgsl" }, + { + wgsl: ` +@group(3) @binding(1) var uMask : texture_2d; +@group(3) @binding(2) var uMaskSampler : sampler; +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return textureSample(uMask, uMaskSampler, vec2f(0.5)) * color.a; +}`, + }, + ); + }); + renderer.customShader = effect; + effect.setTexture("uMask", { width: 4, height: 4 }); + batcher.addQuad(atlas, 0, 0, 16, 16, 0, 0, 0.25, 1, 0xffffffff); + batcher.addQuad(atlas, 20, 0, 16, 16, 0, 0, 0.25, 1, 0xffffffff); + expect(renderer.calls.drawIndexed).toEqual([]); + effect.setTexture("uMask", { width: 4, height: 4 }); + expect(renderer.calls.drawIndexed).toEqual([12]); + }); + + it("a uniform array changed in place draws the sprites queued with the old value", () => { + // callers reuse one array: the value it held is what is compared + const effect = quiet(() => { + return new ShaderEffect( + { shaderLanguage: "wgsl" }, + { + wgsl: ` +struct U { uRect : vec4f }; +@group(3) @binding(0) var fx : U; +fn apply(color : vec4f, uv : vec2f) -> vec4f { return fx.uRect * color.a; }`, + }, + ); + }); + renderer.customShader = effect; + const rect = [0, 0, 0.25, 1]; + effect.setUniform("uRect", rect); + batcher.addQuad(atlas, 0, 0, 16, 16, 0, 0, 0.25, 1, 0xffffffff); + rect[0] = 0.25; + rect[2] = 0.5; + effect.setUniform("uRect", rect); + expect(renderer.calls.drawIndexed).toEqual([6]); + batcher.addQuad(atlas, 20, 0, 16, 16, 0.25, 0, 0.5, 1, 0xffffffff); + // the same array, unchanged: still queued + effect.setUniform("uRect", rect); + expect(renderer.calls.drawIndexed).toEqual([6]); + }); + + it("a screen_texture effect still draws each sprite on its own", () => { + const effect = quiet(() => { + return new ShaderEffect( + { shaderLanguage: "wgsl" }, + { + wgsl: "fn apply(color : vec4f, uv : vec2f) -> vec4f { return textureSample(screen_texture, screen_sampler, screen_uv) * color; }", + }, + ); + }); + renderer.customShader = effect; + batcher.addQuad(atlas, 0, 0, 16, 16, 0, 0, 0.25, 1, 0xffffffff); + batcher.addQuad(atlas, 20, 0, 16, 16, 0, 0, 0.25, 1, 0xffffffff); + expect(renderer.calls.drawIndexed).toEqual([6, 6]); + }); +}); diff --git a/packages/melonjs/tests/effect-frame-bounds.spec.js b/packages/melonjs/tests/effect-frame-bounds.spec.js index 932d5f96ee..f4ed4306ae 100644 --- a/packages/melonjs/tests/effect-frame-bounds.spec.js +++ b/packages/melonjs/tests/effect-frame-bounds.spec.js @@ -297,17 +297,31 @@ describe("effects keep to the sprite's frame (WebGL)", () => { return out; }; - /** run `fn` with the effects sampling as they did before frames were kept to */ + /** + * run `fn` with the effects sampling as they did before frames were kept + * to: every quad drawn with no frame (as a blit is), and no half texel + */ const unbounded = (fn) => { const proto = ShaderEffect.prototype; - const setFrameRect = proto._setFrameRect; - proto._setFrameRect = function () { + const setFrameTexel = proto._setFrameTexel; + const vertices = renderer.batchers.get("quad").vertexData; + const pushSpriteQuad = vertices.pushSpriteQuad; + // fail loudly rather than silently compare a frame with itself, should + // either hook be renamed + expect(typeof setFrameTexel).toBe("function"); + expect(typeof pushSpriteQuad).toBe("function"); + proto._setFrameTexel = function () { this._clearFrameRect(); }; + vertices.pushSpriteQuad = function (...args) { + args[10] = false; + return pushSpriteQuad.apply(this, args); + }; try { return fn(); } finally { - proto._setFrameRect = setFrameRect; + proto._setFrameTexel = setFrameTexel; + vertices.pushSpriteQuad = pushSpriteQuad; } }; diff --git a/packages/melonjs/tests/helpers/webgpu-mock-renderer.js b/packages/melonjs/tests/helpers/webgpu-mock-renderer.js index 6b3e5b82a3..40a522e9d2 100644 --- a/packages/melonjs/tests/helpers/webgpu-mock-renderer.js +++ b/packages/melonjs/tests/helpers/webgpu-mock-renderer.js @@ -1,4 +1,5 @@ import { Color, Matrix3d } from "../../src/index.js"; +import Renderer from "../../src/video/renderer.js"; /** * A mock WebGPU renderer for unit-testing batchers without a GPU device: @@ -277,15 +278,37 @@ export function createMockWebGPURenderer() { // one stable record per atlas object (the lit batcher composes // combined bind groups from the raw view) records: new Map(), - getResidentRecord(texture) { + // options are recorded like getBinding's; a record gains a mip + // chain the first time a draw asks for one, as in the real store + residentOptions: [], + getResidentRecord(texture, options) { + this.residentOptions.push({ texture, options }); if (!this.records.has(texture)) { this.records.set(texture, { view: { texture }, width: 64, height: 64, + mipLevelCount: 1, }); } - return this.records.get(texture); + const record = this.records.get(texture); + if (options?.mipmaps === true) { + record.mipLevelCount = 7; + } + return record; + }, + // the real store's pairing: mipmaps only for a linear filter over + // a record that has a chain + viewAndSampler(record, texture, wrap, wantMips) { + const filter = + typeof texture.filter === "string" ? texture.filter : "linear"; + const mip = + wantMips === true && record.mipLevelCount > 1 && filter === "linear"; + return { + view: record.view, + sampler: this.getSampler(filter, wrap, mip), + key: `${filter}|${wrap}|${mip ? "mip" : "flat"}`, + }; }, samplers: new Map(), getSampler(filter, repeat, mipmaps = false) { @@ -301,6 +324,11 @@ export function createMockWebGPURenderer() { getDefaultTextureFilter() { return "linear"; }, + // the application settings the batchers read + settings: { textureFilter: "auto" }, + wantsMipmaps(texture) { + return Renderer.prototype.wantsMipmaps.call(this, texture); + }, ensurePass() { return pass; }, diff --git a/packages/melonjs/tests/lit_quad_specular.spec.js b/packages/melonjs/tests/lit_quad_specular.spec.js index 07ca0f4a5f..e7a85c0e4b 100644 --- a/packages/melonjs/tests/lit_quad_specular.spec.js +++ b/packages/melonjs/tests/lit_quad_specular.spec.js @@ -88,40 +88,83 @@ describe("2D specular", () => { }); describe("the vertex layout", () => { - it("writes the exponent into the layout's last slot", (ctx) => { + it("writes the exponent into the layout's last slot, after the frame", (ctx) => { requireWebGL(ctx, renderer); const batcher = renderer.setBatcher("litQuad"); batcher.vertexData.clear(); const size = batcher.vertexData.vertexSize; - // the layout's last slot is aShininess - expect(size).toBe(9); - - batcher.vertexData.push(1, 2, 0, 0, 0, 0xffffffff, 0, 3, 48); - expect(batcher.vertexData.bufferF32[8]).toBe(48); + // the two-float aFrame fifth, as in every sprite layout, then + // aNormalTextureId and aShininess + expect(size).toBe(11); + + batcher.vertexData.pushSprite( + 1, + 2, + 0, + 0, + 0, + 0xffffffff, + 0, + 0.25, + 0.5, + 3, + 48, + ); + const f32 = batcher.vertexData.bufferF32; + expect([f32[7], f32[8]]).toEqual([0.25, 0.5]); + expect(f32[9]).toBe(3); + expect(f32[10]).toBe(48); }); - it("REGRESSION: an omitted exponent writes 0, not the -1 unlit sentinel", (ctx) => { + it("REGRESSION: an unlit sprite carries -1 for its normal map and 0 for its exponent", (ctx) => { requireWebGL(ctx, renderer); - // -1 is the sentinel for `aNormalTextureId` one slot earlier; a - // negative exponent reaching `pow()` would explode, so the two - // defaults deliberately differ + // -1 is the "no normal map" sentinel the shader's unlit path keys + // on, 0 a matte exponent; the two differ on purpose, as a + // negative exponent reaching `pow()` would explode, and 0 for + // the normal map would shade an unlit sprite with whatever sits + // in slot 0 const batcher = renderer.setBatcher("litQuad"); batcher.vertexData.clear(); - // poison the slot first: `clear()` only resets the vertex count, so - // a fresh buffer reads 0 there anyway and the assertion below would - // pass on a build that never writes it at all - batcher.vertexData.bufferF32[8] = -999; - batcher.vertexData.push(1, 2, 0, 0, 0, 0xffffffff, 0); - expect(batcher.vertexData.bufferF32[7]).toBe(-1); - expect(batcher.vertexData.bufferF32[8]).toBe(0); + // poison both slots: `clear()` only resets the vertex count, so + // a build that never wrote them would still read 0 there + batcher.vertexData.bufferF32[9] = -999; + batcher.vertexData.bufferF32[10] = -999; + renderer.currentShininess = undefined; + const image = document.createElement("canvas"); + image.width = image.height = 8; + batcher.addQuad( + renderer.cache.get(image), + 0, + 0, + 8, + 8, + 0, + 0, + 1, + 1, + 0xffffffff, + false, + null, + ); + expect(batcher.vertexData.bufferF32[9]).toBe(-1); + expect(batcher.vertexData.bufferF32[10]).toBe(0); + batcher.vertexData.clear(); + renderer.currentShininess = 0; }); - it("REGRESSION: the unlit quad layout is untouched", (ctx) => { + it("REGRESSION: the lit terms stay off the unlit quad layout", (ctx) => { requireWebGL(ctx, renderer); // widening the shared layout would charge every unlit sprite in - // the engine 4 bytes a vertex for a term it never evaluates - expect(renderer.setBatcher("quad").stride).toBe(28); - expect(renderer.setBatcher("litQuad").stride).toBe(36); + // the engine 4 bytes a vertex for each term it never evaluates + const quad = renderer.setBatcher("quad"); + const lit = renderer.setBatcher("litQuad"); + const names = quad.attributes.map((a) => { + return a.name; + }); + expect(names).not.toContain("aNormalTextureId"); + expect(names).not.toContain("aShininess"); + // the normal map slot and the exponent, and nothing else + expect(lit.stride - quad.stride).toBe(8); }); }); diff --git a/packages/melonjs/tests/sprite-frame-clamp.spec.js b/packages/melonjs/tests/sprite-frame-clamp.spec.js new file mode 100644 index 0000000000..d5c336b32c --- /dev/null +++ b/packages/melonjs/tests/sprite-frame-clamp.spec.js @@ -0,0 +1,1392 @@ +/** + * A sprite keeps its samples inside its own frame under the + * `"linear-mipmap"` texture filter. + * + * A sprite cut from an atlas draws one frame of a bigger texture. Drawn + * small, it reads a mipmap level whose texels span many of the art's pixels, + * and a sample near the frame's edge reaches into the next frame: the next + * frame's edge shows as a faint line along the sprite's. Every sprite vertex + * carries the UV of its quad's opposite corner, and the sprite shaders keep + * each sample half a texel of the level it reads inside the frame. + * + * Read in pixels on WebGL, from an atlas of four frames: the one drawn is + * transparent around a green square, and its three neighbours are solid red, + * blue and yellow. Anything with red or blue in it came from a neighbour. + * Every bleed assertion is paired with a run of the same scene with the + * clamp forced off, which must bleed, so a scene that cannot show the + * defect cannot pass for one that fixed it. + */ +import "./helpers/webgpu-globals.js"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { Application, boot, Matrix3d, video } from "../src/index.js"; +import VertexArrayBuffer from "../src/video/buffer/vertex.js"; +import { + FRAME_CLAMP, + FRAME_UNBOUNDED, + frameClampMode, + isFramed, +} from "../src/video/gpu/frame.js"; +import Renderer from "../src/video/renderer.js"; +import GLShader from "../src/video/webgl/glshader.js"; +import { FRAME_FRAGMENT_GLSL } from "../src/video/webgl/shaders/frameclamp.js"; +import { buildMultiTextureFragment } from "../src/video/webgl/shaders/multitexture.js"; +import { buildLitMultiTextureFragment } from "../src/video/webgl/shaders/multitexture-lit.js"; +import WebGPULitQuadBatcher from "../src/video/webgpu/batchers/lit_quad_batcher.js"; +import WebGPUQuadBatcher from "../src/video/webgpu/batchers/quad_batcher.js"; +import WebGPUUniformRing from "../src/video/webgpu/buffer/uniformring.js"; +import { FRAME_UNIFORM_SIZE } from "../src/video/webgpu/pipeline/bindgroups.js"; +import WebGPUTMXRenderer from "../src/video/webgpu/renderers/tmxlayer/orthogonal.js"; +import quadWGSL from "../src/video/webgpu/shaders/quad.wgsl"; +import quadLitWGSL from "../src/video/webgpu/shaders/quad-lit.wgsl"; +import WebGPURenderer from "../src/video/webgpu/webgpu_renderer.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +describe("the frame a sprite vertex carries", () => { + describe("VertexArrayBuffer#pushSprite", () => { + it("writes the frame after the slot, whatever the layout's width", () => { + // the quad layout (9 floats), the WebGPU lit one (10: shininess), + // and the WebGL lit one (11: normal map slot and shininess) + for (const size of [9, 10, 11]) { + const buffer = new VertexArrayBuffer(size, 4); + buffer.pushSprite(1, 2, 3, 0.25, 0.5, 0xff00ff00, 4, 0.75, 0.125, 7, 8); + const f = buffer.bufferF32; + expect([f[0], f[1], f[2], f[3], f[4]]).toEqual([1, 2, 3, 0.25, 0.5]); + expect(buffer.bufferU32[5]).toBe(0xff00ff00); + expect(f[6]).toBe(4); + // floats 7 and 8 in every layout, so an effect's program reads + // the frame at the same location on any of them + expect([f[7], f[8]]).toEqual([0.75, 0.125]); + if (size > 9) { + expect(f[9]).toBe(7); + } + if (size > 10) { + expect(f[10]).toBe(8); + } + expect(buffer.vertexCount).toBe(1); + } + }); + + it("does not write the extras into a layout that has none", () => { + const buffer = new VertexArrayBuffer(9, 4); + buffer.pushSprite(0, 0, 0, 0, 0, 0, 0, 0.5, 0.5, 99, 98); + // offsets 7 and 8 are the frame, not the extras + expect([buffer.bufferF32[7], buffer.bufferF32[8]]).toEqual([0.5, 0.5]); + }); + }); + + describe("VertexArrayBuffer#pushSpriteQuad", () => { + const corners = [ + { x: 0, y: 0, z: 0 }, + { x: 1, y: 0, z: 0 }, + { x: 0, y: 1, z: 0 }, + { x: 1, y: 1, z: 0 }, + ]; + // [u, v, frameU, frameV] per vertex + const read = (buffer) => { + const f = buffer.bufferF32; + const size = buffer.vertexSize; + return [0, 1, 2, 3].map((v) => { + const o = v * size; + return [f[o + 3], f[o + 4], f[o + 7], f[o + 8]]; + }); + }; + + it("gives each corner the UV of the corner opposite it", () => { + const buffer = new VertexArrayBuffer(9, 4); + buffer.pushSpriteQuad(...corners, 0.25, 0.5, 0.75, 1, 0, 0, true); + expect(read(buffer)).toEqual([ + [0.25, 0.5, 0.75, 1], + [0.75, 0.5, 0.25, 1], + [0.25, 1, 0.75, 0.5], + [0.75, 1, 0.25, 0.5], + ]); + }); + + it("still does when the UVs are flipped", () => { + // a mirrored frame: u0 right of u1. The shader takes the min and + // the max, so the opposite corner is all it needs + const buffer = new VertexArrayBuffer(9, 4); + buffer.pushSpriteQuad(...corners, 0.75, 1, 0.25, 0.5, 0, 0, true); + expect(read(buffer)).toEqual([ + [0.75, 1, 0.25, 0.5], + [0.25, 1, 0.75, 0.5], + [0.75, 0.5, 0.25, 1], + [0.25, 0.5, 0.75, 1], + ]); + }); + + it("marks every corner unbounded for a quad with no frame", () => { + const buffer = new VertexArrayBuffer(9, 4); + buffer.pushSpriteQuad(...corners, 0, 0, 3, 3, 0, 0, false); + for (const [u, v, fu, fv] of read(buffer)) { + // the quad's own UVs are untouched + expect(u === 0 || u === 3).toBe(true); + expect(v === 0 || v === 3).toBe(true); + expect(fu).toBe(FRAME_UNBOUNDED); + expect(fv).toBe(FRAME_UNBOUNDED); + } + }); + + it("writes a lit layout's extras on all four corners", () => { + const buffer = new VertexArrayBuffer(11, 4); + buffer.pushSpriteQuad(...corners, 0, 0, 1, 1, 0, 2, true, 5, 32); + for (let v = 0; v < 4; v++) { + expect(buffer.bufferF32[v * 11 + 6]).toBe(2); + expect(buffer.bufferF32[v * 11 + 9]).toBe(5); + expect(buffer.bufferF32[v * 11 + 10]).toBe(32); + } + }); + }); + + describe("which quads have a frame", () => { + it("every texture but a repeating one", () => { + expect(isFramed({})).toBe(true); + expect(isFramed({ repeat: "no-repeat" })).toBe(true); + for (const repeat of ["repeat", "repeat-x", "repeat-y"]) { + expect(isFramed({ repeat })).toBe(false); + } + }); + + it("are kept to it by the level read under linear-mipmap, by the full-size art while a tile layer draws", () => { + const mode = ( + textureFilter, + drawingTiles = false, + resolved = "linear", + ) => { + return frameClampMode({ + settings: { textureFilter }, + drawingTiles, + getDefaultTextureFilter() { + return resolved; + }, + }); + }; + expect(mode("linear-mipmap")).toBe(FRAME_CLAMP.LEVELS); + expect(mode("linear-mipmap", true)).toBe(FRAME_CLAMP.LEVELS); + for (const filter of ["auto", "nearest", "linear", undefined]) { + expect(mode(filter)).toBe(FRAME_CLAMP.OFF); + } + // a tile layer drawn tile by tile, sampled smoothly + expect(mode("linear", true)).toBe(FRAME_CLAMP.TEXEL); + expect(mode("auto", true, "linear")).toBe(FRAME_CLAMP.TEXEL); + // sampled nearest, two texels never blend: nothing to keep to + expect(mode("nearest", true, "nearest")).toBe(FRAME_CLAMP.OFF); + expect(mode("auto", true, "nearest")).toBe(FRAME_CLAMP.OFF); + }); + }); +}); + +describe("the sprite shaders (GLSL)", () => { + it.for([ + ["unlit", buildMultiTextureFragment(4)], + ["lit", buildLitMultiTextureFragment(4)], + ])("%s: GLSL 3.00, every slot sampled inside the frame", ([, src]) => { + expect(src.startsWith("#version 300 es")).toBe(true); + for (let i = 0; i < 4; i++) { + expect(src).toContain(`frameSample(uSampler${i}, vRegion, gx, gy)`); + } + // no slot samples around the clamp + expect(src).not.toMatch(/texture\(uSampler\d/); + expect(src).not.toContain("texture2D("); + }); + + it.for([ + ["unlit", buildMultiTextureFragment(4)], + ["lit", buildLitMultiTextureFragment(4)], + ])("%s: takes the derivatives before the slot ladder", ([, src]) => { + // inside the ladder control flow is not uniform, and a derivative + // there is undefined + const main = src.indexOf("void main"); + const derivative = src.indexOf("dFdx(vRegion)", main); + const ladder = src.indexOf("if (vTextureId < 0.5)", main); + expect(derivative).toBeGreaterThan(main); + expect(derivative).toBeLessThan(ladder); + }); + it.for([ + ["unlit", buildMultiTextureFragment(4)], + ["lit", buildLitMultiTextureFragment(4)], + ])("%s: no derivative is taken inside the ladder", ([, src]) => { + const ladder = src.slice( + src.indexOf("if (vTextureId < 0.5)"), + src.lastIndexOf("fragColor"), + ); + expect(ladder).not.toMatch(/dFd[xy]/); + }); + + it("frameSample samples with the derivatives it is given", () => { + // computing its own inside the ladder would be undefined, and the + // clamped UV's would read a finer level along the frame's edge + const fn = FRAME_FRAGMENT_GLSL.slice( + FRAME_FRAGMENT_GLSL.indexOf("vec4 frameSample("), + ); + expect(fn).not.toMatch(/dFd[xy]/); + expect(fn).toContain("gx, gy);"); + }); +}); + +describe("the sprite shaders (WGSL)", () => { + // the text of a function, from its name to the closing brace at column 0 + const body = (src, name) => { + const start = src.indexOf(`fn ${name}(`); + return src.slice(start, src.indexOf("\n}", start)); + }; + + it.for([ + ["quad.wgsl", quadWGSL], + ["quad-lit.wgsl", quadLitWGSL], + ])("%s: the fragment stage never reads the frame globals", ([, src]) => { + // the frame-globals group is bound to the VERTEX stage only: a + // fragment read makes the pipeline invalid and blanks every sprite + expect(body(src, "fragment_main")).not.toContain("uFrame."); + expect(body(src, "frameUV")).not.toContain("uFrame."); + // the vertex stage folds the mode into the frame instead + expect(body(src, "vertex_main")).toContain("uFrame.frameClamp"); + }); + + it.for([ + ["quad.wgsl", quadWGSL], + ["quad-lit.wgsl", quadLitWGSL], + ])( + "%s: the fragment learns whether the clamp follows the mipmap level", + ([, src]) => { + // the frame globals are the vertex stage's: it hands the mode over + expect(src).toMatch(/@interpolate\(flat\) vFrameLevels : f32/); + expect(body(src, "vertex_main")).toContain( + "out.vFrameLevels = select(0.0, 1.0, uFrame.frameClamp > 1.5);", + ); + expect(body(src, "frameUV")).toMatch(/levels > 0\.5/); + }, + ); + + it.for([ + ["quad.wgsl", quadWGSL], + ["quad-lit.wgsl", quadLitWGSL], + ])( + "%s: the frame is the quad's corners, unbounded with no frame or the clamp off", + ([, src]) => { + // no WGSL runs headless: these are the lines the WebGPU sprite fix + // stands on, as written + const vertex = body(src, "vertex_main").replace(/\s+/g, " "); + expect(vertex).toContain( + "out.vFrame = select( vec4f(min(aRegion, aFrame), max(aRegion, aFrame)), vec4f(-1.0e6, -1.0e6, 1.0e6, 1.0e6), aFrame.x < -5.0e5 || uFrame.frameClamp < 0.5, );", + ); + }, + ); + + it.for([ + ["quad.wgsl", quadWGSL], + ["quad-lit.wgsl", quadLitWGSL], + ])( + "%s: a sample is kept half a texel of the level read inside the frame", + ([, src]) => { + const frameUV = body(src, "frameUV"); + expect(frameUV).toContain( + "let inset = min(vec2f(0.5 * exp2(lod)) / size, (frame.zw - frame.xy) * 0.5);", + ); + expect(frameUV).toMatch( + /return clamp\(uv, frame\.xy \+ inset, frame\.zw - inset\);/, + ); + }, + ); + + it("quad.wgsl: every slot's sample goes through the frame", () => { + const fragment = body(quadWGSL, "fragment_main"); + const samples = fragment.match(/textureSampleGrad\(/g) ?? []; + const framed = + fragment.match(/textureSampleGrad\(uTexture\d, uSampler\d, frameUV\(/g) ?? + []; + expect(samples.length).toBe(8); + expect(framed.length).toBe(8); + }); + + it("quad-lit.wgsl: the color and the normal map keep to the same frame", () => { + const fragment = body(quadLitWGSL, "fragment_main"); + expect(fragment).toContain( + "textureSampleGrad(uTexture, uSampler, uv, dx, dy)", + ); + expect(fragment).toContain( + "textureSampleGrad(uNormal, uNormalSampler, uv, dx, dy)", + ); + expect(fragment).toMatch(/let uv = frameUV\(in\.vRegion/); + }); + + it("the frame clamp fits the frame-globals block as declared", () => { + // projection (16 floats), lineWidth, frameClamp: past the block's + // size, every pipeline built on it fails + expect((16 + 2) * 4).toBeLessThanOrEqual(FRAME_UNIFORM_SIZE); + for (const src of [quadWGSL, quadLitWGSL]) { + const start = src.indexOf("struct FrameUniforms"); + const struct = src + .slice(start, src.indexOf("};", start)) + .replace(/\/\/[^\n]*/g, ""); + expect(struct).toMatch( + /projection : mat4x4,\s*lineWidth : f32,\s*frameClamp : f32,\s*$/, + ); + } + }); +}); + +describe("sprites keep to their frame (WebGL, in pixels)", () => { + const SIZE = 64; + const FRAME = 32; + let renderer; + let quad; + let atlas; + let initialFilter; + + beforeAll(async () => { + await boot(); + try { + renderer = await getWebGLRenderer(SIZE, SIZE); + } catch { + // no WebGL here: the tests below mark themselves skipped + } + if (renderer) { + renderer.projectionMatrix.ortho(0, SIZE, SIZE, 0, -1000, 1000); + renderer.currentBatcher.setProjection(renderer.projectionMatrix); + initialFilter = renderer.settings.textureFilter; + quad = renderer.batchers.get("quad"); + } + // red | blue over target | yellow; the target transparent around a + // green square + atlas = Renderer.createCanvas(FRAME * 2, FRAME * 2); + const ctx = atlas.getContext("2d"); + ctx.fillStyle = "#ff0000"; + ctx.fillRect(0, 0, FRAME, FRAME); + ctx.fillStyle = "#0000ff"; + ctx.fillRect(FRAME, 0, FRAME, FRAME); + ctx.fillStyle = "#ffff00"; + ctx.fillRect(FRAME, FRAME, FRAME, FRAME); + ctx.fillStyle = "#00ff00"; + ctx.fillRect(8, FRAME + 8, 16, 16); + }); + + afterEach(() => { + renderer?.setTextureFilter(initialFilter); + renderer?.resetTransform(); + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + // force the clamp on or off, past the filter that normally decides it + const forceClamp = (on) => { + for (const name of ["quad", "litQuad"]) { + const batcher = renderer.batchers.get(name); + batcher.flush(); + batcher.defaultShader.setUniform("uFrameClamp", on ? 1 : 0); + renderer.currentProgram = batcher.defaultShader.program; + // so the next setTextureFilter puts the right one back + batcher._frameClamp = on ? 1 : 0; + } + }; + + // the canvas, as the most red or blue any pixel has (bleed from the + // neighbouring frames) and the most green (the frame's own art) + const measure = () => { + const gl = renderer.gl; + const out = new Uint8Array(SIZE * SIZE * 4); + gl.readPixels(0, 0, SIZE, SIZE, gl.RGBA, gl.UNSIGNED_BYTE, out); + let bleed = 0; + let green = 0; + for (let i = 0; i < out.length; i += 4) { + bleed = Math.max(bleed, out[i], out[i + 2]); + green = Math.max(green, out[i + 1]); + } + return { bleed, green }; + }; + + // the target frame, drawn at `scale` from (x, y) + const drawTarget = (scale, x, y) => { + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.translate(x, y); + renderer.scale(scale, scale); + renderer.drawImage(atlas, 0, FRAME, FRAME, FRAME, 0, 0, FRAME, FRAME); + renderer.restore(); + }; + + // drawn small, off the pixel grid: the frame's edges fall at every phase + // of the pixels and of the mipmap levels + const SMALL = [ + [0.23, 1.3, 2.7], + [0.31, 5.6, 3.2], + [0.17, 9.4, 1.8], + [0.42, 2.2, 7.9], + [0.13, 20.6, 30.35], + ]; + + describe("drawn small, under linear-mipmap", () => { + it("takes nothing from the neighbouring frames", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("linear-mipmap"); + for (const [scale, x, y] of SMALL) { + drawTarget(scale, x, y); + renderer.flush(); + const { bleed, green } = measure(); + expect(bleed, `scale ${scale} at ${x},${y}`).toBeLessThanOrEqual(2); + // and the frame's own art is still there + expect(green, `scale ${scale} at ${x},${y}`).toBeGreaterThan(40); + } + }); + + it("CONTROL: the same scenes bleed with the clamp forced off", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("linear-mipmap"); + forceClamp(false); + let worst = 0; + for (const [scale, x, y] of SMALL) { + drawTarget(scale, x, y); + renderer.flush(); + worst = Math.max(worst, measure().bleed); + } + expect(worst).toBeGreaterThan(8); + }); + + it("a mirrored frame (u0 right of u1) keeps to it too", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("linear-mipmap"); + const texture = renderer.cache.get(atlas); + const draw = () => { + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.translate(1.3, 2.7); + renderer.scale(0.23, 0.23); + const batcher = renderer.setBatcher("quad"); + // the target frame's UVs, left and right swapped + batcher.addQuad( + texture, + 0, + 0, + FRAME, + FRAME, + 0.5, + 0.5, + 0, + 1, + 0xffffffff, + ); + renderer.restore(); + renderer.flush(); + return measure(); + }; + expect(draw().bleed).toBeLessThanOrEqual(2); + forceClamp(false); + expect(draw().bleed).toBeGreaterThan(8); + }); + + it.for([ + ["a frame", [0, 0.5, 0.5, 1]], + ["a mirrored frame", [0.5, 0.5, 0, 1]], + ])( + "%s: inside, every pixel is the one it would be without the clamp", + ([, uvs], ctx) => { + // the clamp only moves samples near the frame's edge; the art + // itself must not shift. Drawn at 0.42 from (2.2, 7.9): the + // quad spans 2.2..15.64 by 7.9..21.34, and the pixels at least + // 1.5 px inside it are compared + requireWebGL(ctx); + renderer.setTextureFilter("linear-mipmap"); + const texture = renderer.cache.get(atlas); + const draw = () => { + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.translate(2.2, 7.9); + renderer.scale(0.42, 0.42); + renderer + .setBatcher("quad") + .addQuad(texture, 0, 0, FRAME, FRAME, ...uvs, 0xffffffff); + renderer.restore(); + renderer.flush(); + const gl = renderer.gl; + const out = new Uint8Array(SIZE * SIZE * 4); + gl.readPixels(0, 0, SIZE, SIZE, gl.RGBA, gl.UNSIGNED_BYTE, out); + return out; + }; + const on = draw(); + forceClamp(false); + const off = draw(); + let compared = 0; + let green = 0; + for (let y = 9; y <= 19; y++) { + for (let x = 4; x <= 13; x++) { + const i = ((SIZE - 1 - y) * SIZE + x) * 4; + for (let c = 0; c < 3; c++) { + expect( + Math.abs(on[i + c] - off[i + c]), + `${x},${y}`, + ).toBeLessThanOrEqual(1); + } + green = Math.max(green, on[i + 1]); + compared++; + } + } + expect(compared).toBe(110); + // the comparison covers the art, not just empty frame + expect(green).toBeGreaterThan(200); + }, + ); + + it("quads queued before a filter change draw under the clamp they were queued with", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("linear-mipmap"); + let worst = 0; + for (const [scale, x, y] of SMALL) { + drawTarget(scale, x, y); + // queued, not drawn: the change must draw them first + renderer.setTextureFilter("linear"); + renderer.flush(); + worst = Math.max(worst, measure().bleed); + renderer.setTextureFilter("linear-mipmap"); + } + expect(worst).toBeLessThanOrEqual(2); + }); + }); + + describe("a tile layer drawn tile by tile", () => { + const clampMode = () => { + return quad.defaultShader._uniformCache.uFrameClamp; + }; + + it("keeps its tiles to their own tile under smooth filtering", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("linear"); + renderer.beginTileLayer(); + expect(clampMode()).toBe(FRAME_CLAMP.TEXEL); + drawTarget(3, -0.4, -0.6); + renderer.endTileLayer(); + renderer.flush(); + expect(measure().bleed).toBeLessThanOrEqual(2); + expect(clampMode()).toBe(FRAME_CLAMP.OFF); + }); + + it.each([ + ["a tile layer draws, under linear filtering", "linear", true], + ["under linear-mipmap", "linear-mipmap", false], + ])( + "keeps to the frame on all four sides while %s", + (_when, filter, tiles, ctx) => { + // the frame in the middle of a 3x3 atlas: green, red all around + requireWebGL(ctx); + const sheet = Renderer.createCanvas(FRAME * 3, FRAME * 3); + const g = sheet.getContext("2d"); + g.fillStyle = "#ff0000"; + g.fillRect(0, 0, FRAME * 3, FRAME * 3); + g.fillStyle = "#00ff00"; + g.fillRect(FRAME, FRAME, FRAME, FRAME); + renderer.setTextureFilter(filter); + if (tiles) { + renderer.beginTileLayer(); + } + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.translate(-0.4, -0.6); + renderer.scale(3, 3); + renderer.drawImage( + sheet, + FRAME, + FRAME, + FRAME, + FRAME, + 0, + 0, + FRAME, + FRAME, + ); + renderer.restore(); + if (tiles) { + renderer.endTileLayer(); + } + renderer.flush(); + const { bleed, green } = measure(); + expect(green).toBe(255); + expect(bleed).toBeLessThanOrEqual(2); + }, + ); + + // the frame in the middle of a 3x3 atlas, red all around, and a flat + // normal map for the lit batcher + const surrounded = () => { + const sheet = Renderer.createCanvas(FRAME * 3, FRAME * 3); + const g = sheet.getContext("2d"); + g.fillStyle = "#ff0000"; + g.fillRect(0, 0, FRAME * 3, FRAME * 3); + g.fillStyle = "#00ff00"; + g.fillRect(FRAME, FRAME, FRAME, FRAME); + return sheet; + }; + const flat = Renderer.createCanvas(FRAME * 3, FRAME * 3); + const n = flat.getContext("2d"); + n.fillStyle = "#8080ff"; + n.fillRect(0, 0, FRAME * 3, FRAME * 3); + + // the middle frame drawn at each SMALL scale and offset, lit or not: + // the most red any pixel took, and the most green + const drawSmall = (lit, clamp = true) => { + const sheet = surrounded(); + renderer.setTextureFilter("linear-mipmap"); + if (!clamp) { + forceClamp(false); + } + const litQuad = renderer.batchers.get("litQuad"); + // no light, a white ambient floor: the sprite's own colors + const neutral = (ambient) => { + litQuad.setLightUniforms({ + positions: new Float32Array(4), + colors: new Float32Array(3), + count: 0, + ambient, + }); + }; + const lights = renderer.activeLightCount; + const normals = renderer.currentNormalMap; + let bleed = 0; + let green = 255; + try { + for (const [scale, x, y] of SMALL) { + if (lit) { + renderer.activeLightCount = 1; + renderer.currentNormalMap = flat; + neutral([1, 1, 1]); + } + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.translate(x, y); + renderer.scale(scale, scale); + renderer.drawImage( + sheet, + FRAME, + FRAME, + FRAME, + FRAME, + 0, + 0, + FRAME, + FRAME, + ); + renderer.restore(); + if (lit) { + expect(renderer.currentBatcher).toBe(litQuad); + } + renderer.flush(); + const measured = measure(); + bleed = Math.max(bleed, measured.bleed); + green = Math.min(green, measured.green); + } + } finally { + renderer.activeLightCount = lights; + renderer.currentNormalMap = normals; + neutral([0, 0, 0]); + } + return { bleed, green }; + }; + + it.each([ + ["unlit", false], + ["normal-mapped, through the lit batcher", true], + ])( + "keeps a %s sprite drawn small to its frame on all four sides", + (_kind, lit, ctx) => { + requireWebGL(ctx); + const { bleed, green } = drawSmall(lit); + expect(green).toBeGreaterThan(40); + expect(bleed).toBeLessThanOrEqual(2); + }, + ); + + it("CONTROL: the lit scenes bleed with the clamp forced off", (ctx) => { + requireWebGL(ctx); + expect(drawSmall(true, false).bleed).toBeGreaterThan(20); + }); + + it("CONTROL: a sprite drawn outside a tile layer still samples as it always did", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("linear"); + drawTarget(3, -0.4, -0.6); + renderer.flush(); + expect(measure().bleed).toBeGreaterThan(8); + }); + + it("leaves nearest sampling and its draws alone", (ctx) => { + // nearest never blends two texels: no clamp, so no draw is split + requireWebGL(ctx); + renderer.setTextureFilter("nearest"); + renderer.beginTileLayer(); + expect(clampMode()).toBe(FRAME_CLAMP.OFF); + renderer.endTileLayer(); + }); + + it("keeps the mipmap clamp under linear-mipmap", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("linear-mipmap"); + renderer.beginTileLayer(); + expect(clampMode()).toBe(FRAME_CLAMP.LEVELS); + renderer.endTileLayer(); + expect(clampMode()).toBe(FRAME_CLAMP.LEVELS); + }); + + it("is what Renderer#drawTileLayer draws a tile-by-tile layer inside", (ctx) => { + requireWebGL(ctx); + const seen = []; + const layer = { + renderMode: "perTile", + getRenderer() { + return { + drawTileLayer(r) { + seen.push(r.drawingTiles); + }, + }; + }, + }; + renderer.drawTileLayer(layer, { + pos: { x: 0, y: 0 }, + width: 1, + height: 1, + }); + expect(seen).toEqual([true]); + expect(renderer.drawingTiles).toBe(false); + // and closes it even when a tile throws + const failing = { + renderMode: "perTile", + getRenderer() { + return { + drawTileLayer() { + throw new Error("tile"); + }, + }; + }, + }; + expect(() => { + renderer.drawTileLayer(failing, { + pos: { x: 0, y: 0 }, + width: 1, + height: 1, + }); + }).toThrow("tile"); + expect(renderer.drawingTiles).toBe(false); + }); + }); + + describe("drawn large", () => { + it("under linear-mipmap, takes nothing from the next frame either", (ctx) => { + // half a texel of level 0 along the edge, which plain bilinear + // sampling blends with the next frame + requireWebGL(ctx); + renderer.setTextureFilter("linear-mipmap"); + drawTarget(3, -0.4, -0.6); + renderer.flush(); + expect(measure().bleed).toBeLessThanOrEqual(2); + }); + + it("under plain linear, samples exactly as it always did", (ctx) => { + // the clamp is off: half a texel of the frame above still blends + // in along the top edge, as it did before + requireWebGL(ctx); + renderer.setTextureFilter("linear"); + drawTarget(3, -0.4, -0.6); + renderer.flush(); + expect(measure().bleed).toBeGreaterThan(8); + }); + }); + + describe("the frame each quad carries", () => { + // the opposite-corner UV of the four corners pushed by `draw` + const framesOf = (draw) => { + const frames = []; + const push = quad.vertexData.pushSprite; + quad.vertexData.pushSprite = function (...args) { + frames.push([args[7], args[8]]); + return push.apply(this, args); + }; + try { + draw(); + } finally { + quad.vertexData.pushSprite = push; + renderer.flush(); + } + return frames; + }; + + it("a frame of an atlas: its own corners", (ctx) => { + requireWebGL(ctx); + const frames = framesOf(() => { + renderer.drawImage(atlas, 0, FRAME, FRAME, FRAME, 0, 0, FRAME, FRAME); + }); + expect(frames).toEqual([ + [0.5, 1], + [0, 1], + [0.5, 0.5], + [0, 0.5], + ]); + }); + + it("a repeating pattern: none, so it still wraps", (ctx) => { + requireWebGL(ctx); + const pattern = renderer.createPattern(atlas, "repeat"); + const frames = framesOf(() => { + renderer.drawPattern(pattern, 0, 0, SIZE * 3, SIZE * 3); + }); + expect(frames).toHaveLength(4); + for (const frame of frames) { + expect(frame).toEqual([FRAME_UNBOUNDED, FRAME_UNBOUNDED]); + } + }); + + it("a repeating pattern draws the same with the clamp on or off", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("linear-mipmap"); + const pattern = renderer.createPattern(atlas, "repeat"); + const draw = () => { + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.translate(0.3, 0.7); + renderer.scale(0.4, 0.4); + renderer.drawPattern(pattern, 0, 0, SIZE * 3, SIZE * 3); + renderer.restore(); + renderer.flush(); + const gl = renderer.gl; + const out = new Uint8Array(SIZE * SIZE * 4); + gl.readPixels(0, 0, SIZE, SIZE, gl.RGBA, gl.UNSIGNED_BYTE, out); + return out; + }; + const on = draw(); + forceClamp(false); + const off = draw(); + expect(Array.from(on)).toEqual(Array.from(off)); + }); + }); + + describe("the layouts", () => { + it("put the frame fifth: after what a custom shader declares, before the lit extras", (ctx) => { + requireWebGL(ctx); + const expected = { + quad: ["aVertex", "aRegion", "aColor", "aTextureId", "aFrame"], + litQuad: [ + "aVertex", + "aRegion", + "aColor", + "aTextureId", + "aFrame", + "aNormalTextureId", + "aShininess", + ], + }; + for (const [name, attributes] of Object.entries(expected)) { + const batcher = renderer.batchers.get(name); + expect( + batcher.attributes.map((a) => { + return a.name; + }), + ).toEqual(attributes); + const frame = batcher.attributes[4]; + // two floats, after the 28 bytes of the first four + expect(frame.offset).toBe(28); + expect(frame.format).toBe("float32x2"); + expect(batcher.defaultShader.getAttribLocation("aFrame")).toBe(4); + } + }); + + it("a custom shader declaring the first three attributes still draws", (ctx) => { + // what a user GLShader written for the old layout declares + requireWebGL(ctx); + const shader = new GLShader( + renderer.gl, + [ + "attribute vec3 aVertex;", + "attribute vec2 aRegion;", + "attribute vec4 aColor;", + "uniform mat4 uProjectionMatrix;", + "varying vec2 vRegion;", + "void main(void) {", + " gl_Position = uProjectionMatrix * vec4(aVertex, 1.0);", + " vRegion = aRegion;", + "}", + ].join("\n"), + [ + "uniform sampler2D uSampler;", + "varying vec2 vRegion;", + "void main(void) {", + " float a = max(texture2D(uSampler, vRegion).a, 1.0);", + " gl_FragColor = vec4(0.0, 0.0, a, 1.0);", + "}", + ].join("\n"), + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + renderer.clearColor("#000000ff"); + renderer.customShader = shader; + renderer.drawImage(atlas, 0, 0, FRAME, FRAME, 8, 8, 16, 16); + renderer.customShader = undefined; + renderer.flush(); + const mismatch = warn.mock.calls.some((args) => { + return String(args[0]).includes("attribute location mismatch"); + }); + expect(mismatch).toBe(false); + } finally { + renderer.customShader = undefined; + warn.mockRestore(); + } + const gl = renderer.gl; + const pixel = new Uint8Array(4); + // (16, 16) from the top: inside the drawn square + gl.readPixels(16, SIZE - 1 - 16, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + expect(Array.from(pixel.slice(0, 3))).toEqual([0, 0, 255]); + shader.destroy(); + }); + + it("the lit batcher leaves a repeating pattern unbounded", (ctx) => { + requireWebGL(ctx); + const lit = renderer.batchers.get("litQuad"); + const pattern = renderer.createPattern(atlas, "repeat"); + const normalMap = Renderer.createCanvas(FRAME * 2, FRAME * 2); + const frames = []; + const push = lit.vertexData.pushSprite; + lit.vertexData.pushSprite = function (...args) { + frames.push([args[7], args[8]]); + return push.apply(this, args); + }; + try { + renderer.setBatcher("litQuad"); + lit.addQuad( + pattern, + 0, + 0, + 8, + 8, + 0, + 0, + 3, + 3, + 0xffffffff, + false, + normalMap, + ); + } finally { + lit.vertexData.pushSprite = push; + lit.vertexData.clear(); + renderer.setBatcher("quad"); + } + expect(frames).toHaveLength(4); + for (const frame of frames) { + expect(frame).toEqual([FRAME_UNBOUNDED, FRAME_UNBOUNDED]); + } + }); + + it("the lit batcher writes its frame after the normal map slot and shininess", (ctx) => { + requireWebGL(ctx); + const lit = renderer.batchers.get("litQuad"); + const normalMap = Renderer.createCanvas(FRAME * 2, FRAME * 2); + const texture = renderer.cache.get(atlas); + const calls = []; + const push = lit.vertexData.pushSprite; + lit.vertexData.pushSprite = function (...args) { + calls.push(args); + return push.apply(this, args); + }; + try { + renderer.currentShininess = 24; + renderer.setBatcher("litQuad"); + lit.addQuad( + texture, + 0, + 0, + 8, + 8, + 0, + 0.5, + 0.5, + 1, + 0xffffffff, + false, + normalMap, + ); + } finally { + lit.vertexData.pushSprite = push; + renderer.currentShininess = 0; + lit.vertexData.clear(); + renderer.setBatcher("quad"); + } + expect(calls).toHaveLength(4); + // [frameU, frameV, normal map slot, shininess] of the first corner + const [, , , , , , , fu, fv, normal, shininess] = calls[0]; + expect([fu, fv]).toEqual([0.5, 1]); + expect(normal).toBeGreaterThanOrEqual(0); + expect(shininess).toBe(24); + }); + }); +}); + +describe("sprites keep to their frame (WebGPU, mock renderer)", () => { + let renderer; + let batcher; + + const atlas = (fields = {}) => { + return { + ...fields, + getTexture() { + return { width: 64, height: 64 }; + }, + }; + }; + + // [frameU, frameV] of each of the batcher's pending vertices + const framesOf = (target) => { + const size = target.vertexSize; + const f = target.vertexData.bufferF32; + const out = []; + for (let v = 0; v < target.vertexData.vertexCount; v++) { + out.push([f[v * size + 7], f[v * size + 8]]); + } + return out; + }; + + beforeEach(() => { + renderer = createMockWebGPURenderer(); + batcher = new WebGPUQuadBatcher(renderer); + }); + + it("the quad layout: 36 bytes, the frame fifth", () => { + const frame = batcher.attributes[4]; + expect(frame.name).toBe("aFrame"); + expect(frame.format).toBe("float32x2"); + expect(frame.offset).toBe(28); + expect(batcher.stride).toBe(36); + }); + + it("the lit layout: 40 bytes, the frame fifth, then the shininess", () => { + const lit = new WebGPULitQuadBatcher(renderer); + expect( + lit.attributes.map((a) => { + return a.name; + }), + ).toEqual([ + "aVertex", + "aRegion", + "aColor", + "aTextureId", + "aFrame", + "aShininess", + ]); + expect(lit.attributes[4].offset).toBe(28); + expect(lit.stride).toBe(40); + }); + + it("a frame of an atlas: each corner carries the opposite one", () => { + batcher.addQuad(atlas(), 0, 0, 8, 8, 0, 0.5, 0.5, 1, 0xffffffff); + expect(framesOf(batcher)).toEqual([ + [0.5, 1], + [0, 1], + [0.5, 0.5], + [0, 0.5], + ]); + }); + + it("a repeating texture, batched or through an effect: no frame", () => { + batcher.addQuad( + atlas({ repeat: "repeat" }), + 0, + 0, + 8, + 8, + 0, + 0, + 3, + 3, + 0xffffffff, + ); + for (const frame of framesOf(batcher)) { + expect(frame).toEqual([FRAME_UNBOUNDED, FRAME_UNBOUNDED]); + } + // the effect path flushes per quad: read what it pushed + const effectBatcher = new WebGPUQuadBatcher(renderer); + renderer.customShader = {}; + const pushed = []; + const push = effectBatcher.vertexData.pushSprite; + effectBatcher.vertexData.pushSprite = function (...args) { + pushed.push([args[7], args[8]]); + return push.apply(this, args); + }; + effectBatcher.addQuad( + atlas({ repeat: "repeat-x" }), + 0, + 0, + 8, + 8, + 0, + 0, + 3, + 1, + 0xffffffff, + ); + expect(pushed).toHaveLength(4); + for (const frame of pushed) { + expect(frame).toEqual([FRAME_UNBOUNDED, FRAME_UNBOUNDED]); + } + }); + + it("the lit batcher leaves a repeating texture unbounded too", () => { + const lit = new WebGPULitQuadBatcher(renderer); + lit.addQuad( + atlas({ repeat: "repeat" }), + 0, + 0, + 8, + 8, + 0, + 0, + 3, + 3, + 0xffffffff, + false, + { + width: 4, + height: 4, + }, + ); + const f = lit.vertexData.bufferF32; + for (let v = 0; v < 4; v++) { + expect([f[v * 10 + 7], f[v * 10 + 8]]).toEqual([ + FRAME_UNBOUNDED, + FRAME_UNBOUNDED, + ]); + } + }); + + it("the lit batcher writes the frame, then the shininess", () => { + const lit = new WebGPULitQuadBatcher(renderer); + renderer.currentShininess = 16; + lit.addQuad(atlas(), 0, 0, 8, 8, 0, 0.5, 0.5, 1, 0xffffffff, false, { + width: 4, + height: 4, + }); + const f = lit.vertexData.bufferF32; + expect([f[7], f[8]]).toEqual([0.5, 1]); + expect(f[9]).toBe(16); + }); + + it("a tile layer stages its quad in the batcher's layout, with no frame", () => { + renderer.setBatcher = () => { + return batcher; + }; + const tmx = new WebGPUTMXRenderer(renderer); + tmx.draw( + { + cols: 4, + rows: 4, + tilewidth: 16, + tileheight: 16, + dataVersion: 1, + layerData: new Uint16Array(32), + tint: null, + getOpacity() { + return 1; + }, + tilesets: { + tilesets: [ + { + isCollection: false, + image: { width: 64, height: 64 }, + tilewidth: 16, + tileheight: 16, + margin: 0, + spacing: 0, + firstgid: 1, + lastgid: 16, + isAnimated: false, + animations: new Map(), + texture: atlas(), + }, + ], + }, + }, + { pos: { x: 0, y: 0 }, width: 64, height: 64 }, + ); + const vertexWrite = renderer.calls.writes.find((w) => { + return w.buffer === renderer.vertexArena.page; + }); + expect(vertexWrite.size).toBe(4 * batcher.stride); + const size = batcher.vertexSize; + for (let v = 0; v < 4; v++) { + expect(vertexWrite.floats[v * size + 7]).toBe(FRAME_UNBOUNDED); + expect(vertexWrite.floats[v * size + 8]).toBe(FRAME_UNBOUNDED); + } + // and its UVs where they were: the corners of the layer + expect([vertexWrite.floats[3], vertexWrite.floats[4]]).toEqual([0, 0]); + const last = 3 * size; + expect([ + vertexWrite.floats[last + 3], + vertexWrite.floats[last + 4], + ]).toEqual([1, 1]); + }); + + describe("the clamp mode in the frame globals", () => { + const ring = () => { + const writes = []; + const device = { + limits: { minUniformBufferOffsetAlignment: 256 }, + createBuffer(descriptor) { + return { size: descriptor.size, destroy() {} }; + }, + createBindGroup(descriptor) { + return { layout: descriptor.layout }; + }, + queue: { + writeBuffer(buffer, offset, data, dataOffset, size) { + writes.push( + Array.from( + new Float32Array(data.slice(dataOffset, dataOffset + size)), + ), + ); + }, + }, + }; + return { + writes, + ring: new WebGPUUniformRing(device, { frame: true }, { clear: true }), + }; + }; + + it("rides right after the line width", () => { + const { writes, ring: r } = ring(); + r.pushFrameGlobals(new Matrix3d(), 3, 1); + expect(writes[0][16]).toBe(3); + expect(writes[0][17]).toBe(1); + }); + + it("is 0 when not given, whatever the last slot held", () => { + const { writes, ring: r } = ring(); + r.pushFrameGlobals(new Matrix3d(), 1, 1); + r.pushFrameGlobals(new Matrix3d(), 1); + expect(writes[1][17]).toBe(0); + }); + + it("a tile layer's clamp: a new slot when the mode changes, open pass or not", () => { + const calls = []; + const fake = { + settings: { textureFilter: "linear" }, + drawingTiles: false, + renderPass: {}, + uniformRing: {}, + getDefaultTextureFilter() { + return this.settings.textureFilter === "nearest" + ? "nearest" + : "linear"; + }, + currentBatcher: { + flush() { + calls.push("flush"); + }, + }, + pushFrameGlobals() { + calls.push(["push", frameClampMode(this)]); + }, + }; + const set = WebGPURenderer.prototype.setDrawingTiles; + set.call(fake, true); + set.call(fake, false); + // the queued quads drawn first, then a slot with the new mode + expect(calls).toEqual([ + "flush", + ["push", FRAME_CLAMP.TEXEL], + "flush", + ["push", FRAME_CLAMP.OFF], + ]); + // nearest: the mode does not change, nothing is drawn or pushed + calls.length = 0; + fake.settings.textureFilter = "nearest"; + set.call(fake, true); + set.call(fake, false); + expect(calls).toEqual([]); + // no pass open, as after a post effect, a mask or a flush in the + // middle of a frame: the next draws still need the new slot + fake.settings.textureFilter = "linear"; + fake.renderPass = null; + set.call(fake, true); + expect(calls).toEqual(["flush", ["push", FRAME_CLAMP.TEXEL]]); + // before the renderer has a uniform ring: nothing to push into + calls.length = 0; + fake.uniformRing = null; + set.call(fake, false); + expect(calls).toEqual([]); + expect(fake.drawingTiles).toBe(false); + }); + + it("follows a filter change, open pass or not", () => { + // the renderer's own setTextureFilter, on a stand-in for the rest + const pushes = []; + const fake = { + settings: { textureFilter: "linear" }, + currentBatcher: { flush() {} }, + textureStore: { invalidateBindGroups() {} }, + batchers: new Map(), + renderPass: {}, + uniformRing: {}, + pushFrameGlobals() { + pushes.push(frameClampMode(this)); + }, + }; + WebGPURenderer.prototype.setTextureFilter.call(fake, "linear-mipmap"); + expect(pushes).toEqual([FRAME_CLAMP.LEVELS]); + // no pass open, mid-frame or not: the next draws read the new slot + fake.renderPass = null; + WebGPURenderer.prototype.setTextureFilter.call(fake, "linear"); + expect(pushes).toEqual([FRAME_CLAMP.LEVELS, FRAME_CLAMP.OFF]); + // before the renderer has a uniform ring: nothing to push into + fake.uniformRing = null; + WebGPURenderer.prototype.setTextureFilter.call(fake, "linear-mipmap"); + expect(pushes).toEqual([FRAME_CLAMP.LEVELS, FRAME_CLAMP.OFF]); + }); + }); +}); + +describe("a game created under linear-mipmap", () => { + let app; + + afterAll(() => { + app?.destroy(); + }); + + it("keeps its sprites to their frames from its first frame", async (ctx) => { + // the clamp mode is set when the batchers are made, not only when + // the filter changes later + await boot(); + try { + app = new Application(32, 32, { + renderer: video.WEBGL, + textureFilter: "linear-mipmap", + }); + await app.init(); + } catch { + ctx.skip("WebGL renderer not available in this environment"); + } + if (app.renderer.type !== "WebGL2") { + ctx.skip("WebGL renderer not available in this environment"); + } + for (const name of ["quad", "litQuad"]) { + const batcher = app.renderer.batchers.get(name); + expect(batcher.defaultShader._uniformCache.uFrameClamp).toBe( + FRAME_CLAMP.LEVELS, + ); + } + }); +}); diff --git a/packages/melonjs/tests/squish-effect.spec.js b/packages/melonjs/tests/squish-effect.spec.js index ab5d9775a7..c687b2906b 100644 --- a/packages/melonjs/tests/squish-effect.spec.js +++ b/packages/melonjs/tests/squish-effect.spec.js @@ -95,16 +95,19 @@ describe("SquishEffect", () => { return { width: 64, height: 32 }; }, }; - const quadOf = (renderer) => { + // the quad the batcher drew: effect sprites share a draw, so the + // queued one is drawn first + const quadOf = (renderer, batcher) => { + batcher.flush(); const f = renderer.calls.writes.at(-1).floats; - // corners: (x,y) (x+w,y) (x,y+h) (x+w,y+h), stride 7 floats + // corners: (x,y) (x+w,y) (x,y+h) (x+w,y+h), stride 9 floats return { left: f[0], - right: f[7], + right: f[9], top: f[1], - bottom: f[15], + bottom: f[19], u0: f[3], - u1: f[10], + u1: f[12], }; }; const make = (bulge) => { @@ -125,7 +128,7 @@ describe("SquishEffect", () => { const { renderer, batcher } = make(0.5); // the right half of a 64x32 sheet, drawn 32x32 at (16, 0) batcher.addQuad(atlas, 16, 0, 32, 32, 0.5, 0, 1, 1, 0xffffffff); - const quad = quadOf(renderer); + const quad = quadOf(renderer, batcher); // 1.5x as wide about the centre: x 8..56 expect([quad.left, quad.right]).toEqual([8, 56]); expect([quad.top, quad.bottom]).toEqual([0, 32]); @@ -149,7 +152,7 @@ describe("SquishEffect", () => { }, ); batcher.addQuad(atlas, 16, 0, 32, 32, 0.5, 0, 1, 1, 0xffffffff); - const quad = quadOf(renderer); + const quad = quadOf(renderer, batcher); expect([quad.left, quad.right]).toEqual([16, 48]); expect([quad.u0, quad.u1]).toEqual([0.5, 1]); }); @@ -157,7 +160,7 @@ describe("SquishEffect", () => { it("draws the quad as it is when it pinches in", () => { const { renderer, batcher } = make(-0.5); batcher.addQuad(atlas, 16, 0, 32, 32, 0.5, 0, 1, 1, 0xffffffff); - const quad = quadOf(renderer); + const quad = quadOf(renderer, batcher); expect([quad.left, quad.right]).toEqual([16, 48]); expect([quad.u0, quad.u1]).toEqual([0.5, 1]); }); @@ -298,6 +301,170 @@ describe("SquishEffect", () => { squish.destroy(); }); + describe("kept to its own frame", () => { + /** + * 64x8: blue | a 4x4 red frame over a yellow one | green. The red + * frame drawn 16 times its size: smooth filtering reaches across + * three of its edges into a neighbour (the top one is the + * texture's own edge) + */ + const edges = () => { + const source = Renderer.createCanvas(64, 8); + const ctx2d = source.getContext("2d"); + ctx2d.fillStyle = "#0000ff"; + ctx2d.fillRect(0, 0, 32, 8); + ctx2d.fillStyle = "#ffff00"; + ctx2d.fillRect(32, 0, 4, 8); + ctx2d.fillStyle = "#ff0000"; + ctx2d.fillRect(32, 0, 4, 4); + ctx2d.fillStyle = "#00ff00"; + ctx2d.fillRect(36, 0, 28, 8); + return source; + }; + + const drawEdges = (effect, filter) => { + const previous = renderer.settings.textureFilter; + renderer.setTextureFilter(filter); + try { + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.customShader = effect; + renderer.drawImage(edges(), 32, 0, 4, 4, 0, 0, SIZE, SIZE); + renderer.flush(); + renderer.customShader = undefined; + renderer.restore(); + } finally { + renderer.setTextureFilter(previous); + } + }; + + /** the pixels that are not pure red, as "x,y: r,g,b" */ + const notRed = () => { + const gl = renderer.gl; + const out = new Uint8Array(SIZE * SIZE * 4); + gl.readPixels(0, 0, SIZE, SIZE, gl.RGBA, gl.UNSIGNED_BYTE, out); + const wrong = []; + for (let i = 0; i < out.length; i += 4) { + if (out[i] !== 255 || out[i + 1] !== 0 || out[i + 2] !== 0) { + const x = (i / 4) % SIZE; + const y = SIZE - 1 - Math.floor(i / 4 / SIZE); + wrong.push(`${x},${y}: ${out[i]},${out[i + 1]},${out[i + 2]}`); + } + } + return wrong; + }; + + it("never filters in the next frame along its edges", (ctx) => { + requireWebGL(ctx); + const squish = new SquishEffect(renderer); + drawEdges(squish, "linear"); + expect(notRed()).toEqual([]); + squish.destroy(); + }); + + it("CONTROL: the same draw without it does", (ctx) => { + requireWebGL(ctx); + drawEdges(undefined, "linear"); + const wrong = notRed(); + // blue on the left, yellow below, green on the right + expect( + wrong.some((p) => { + return p.startsWith("0,"); + }), + ).toBe(true); + expect( + wrong.some((p) => { + return p.startsWith(`${SIZE - 1},`); + }), + ).toBe(true); + expect( + wrong.some((p) => { + return p.includes(`,${SIZE - 1}:`); + }), + ).toBe(true); + }); + + it("reads the right mipmap level along the edge of its shape", (ctx) => { + // every smaller level painted its own color: drawn 1:1, the + // sprite reads level 0 and 1. A sample taken after a branch + // some pixels of its 2x2 block skipped picks its level from + // undefined derivatives, a far smaller one, which drew a dark + // line along the sprite's edge under linear-mipmap + requireWebGL(ctx); + const previous = renderer.settings.textureFilter; + renderer.setTextureFilter("linear-mipmap"); + const squish = new SquishEffect(renderer, { bulge: 0.6 }); + const source = Renderer.createCanvas(64, 32); + const ctx2d = source.getContext("2d"); + ctx2d.fillStyle = "#0000ff"; + ctx2d.fillRect(0, 0, 32, 32); + ctx2d.fillStyle = "#ff0000"; + ctx2d.fillRect(32, 0, 32, 32); + try { + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.customShader = squish; + renderer.drawImage(source, 32, 0, 32, 32, 16, 16, 32, 32); + renderer.flush(); + const gl = renderer.gl; + gl.bindTexture( + gl.TEXTURE_2D, + renderer.textureStore.peek(source).handle, + ); + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); + for (let level = 1; 64 >> level >= 1; level++) { + const w = 64 >> level; + const h = Math.max(32 >> level, 1); + const painted = new Uint8Array(w * h * 4); + for (let i = 0; i < painted.length; i += 4) { + painted.set([255, 0, level * 30, 255], i); + } + gl.texSubImage2D( + gl.TEXTURE_2D, + level, + 0, + 0, + w, + h, + gl.RGBA, + gl.UNSIGNED_BYTE, + painted, + ); + } + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); + // the same draw again, now that the levels show + renderer.clearColor("#000000ff"); + renderer.drawImage(source, 32, 0, 32, 32, 16, 16, 32, 32); + renderer.flush(); + renderer.customShader = undefined; + renderer.restore(); + const out = new Uint8Array(SIZE * SIZE * 4); + gl.readPixels(0, 0, SIZE, SIZE, gl.RGBA, gl.UNSIGNED_BYTE, out); + // level n is painted with blue = 30 n + let drawn = 0; + const deep = []; + for (let i = 0; i < out.length; i += 4) { + if (out[i] === 255) { + drawn++; + } + if (out[i + 2] >= 45) { + const x = (i / 4) % SIZE; + const y = SIZE - 1 - Math.floor(i / 4 / SIZE); + deep.push(`${x},${y}: level ${Math.round(out[i + 2] / 30)}`); + } + } + // the sprite drew, bulged, and no pixel of it read past level + // 1: near the top and bottom rows the bulge shears the + // texture a little, a blend of level 1 is right there + expect(drawn).toBeGreaterThan(32 * 32); + expect(deep).toEqual([]); + } finally { + renderer.setTextureFilter(previous); + squish.destroy(); + } + }); + }); + it("squishes a Sprite carrying it", (ctx) => { requireWebGL(ctx); // through preDraw / postDraw, as a game draws it: the effect diff --git a/packages/melonjs/tests/sync-to-display.spec.js b/packages/melonjs/tests/sync-to-display.spec.js new file mode 100644 index 0000000000..03600ed7c1 --- /dev/null +++ b/packages/melonjs/tests/sync-to-display.spec.js @@ -0,0 +1,383 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Application, timer, video } from "../src/index.js"; + +/** + * `syncToDisplay`: the update loop counts frame times in whole display frames. + * + * Frame times are made up here: each frame lands on the display's refresh, + * give or take up to `jitter` milliseconds, as a browser reports them (a + * real 60 Hz display measured within 1 ms of 16.7 ms on 99% of its frames). + * The jitter is on when each frame lands, not on how long it took, so the + * frame times of a steady display average out to its refresh exactly. + */ +describe("syncToDisplay", () => { + let app; + + beforeAll(async () => { + app = new Application(64, 64, { + renderer: video.CANVAS, + syncToDisplay: true, + }); + await app.init(); + }); + + afterAll(() => { + app?.destroy(); + timer.syncToDisplay = false; + timer.reset(); + }); + + // a seeded generator, so a failure replays + const random = (seed) => { + return () => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 2 ** 32; + }; + }; + + // the frame times of a display at `hz`, each frame landing within + // `jitter` ms of its refresh; `skip` lists the refreshes it misses + const display = (hz, frames, jitter, seed = 7, skip = []) => { + const next = random(seed); + const period = 1000 / hz; + const deltas = []; + let last = 0; + for (let i = 1; deltas.length < frames; i++) { + if (skip.includes(i)) { + continue; + } + const at = i * period + (next() * 2 - 1) * jitter; + deltas.push(at - last); + last = at; + } + return deltas; + }; + + // run the update loop over `deltas`: the updates each frame ran, and the + // frame time the timer reported for it + const run = (deltas, sync) => { + timer.syncToDisplay = sync; + timer._displayFrame = 0; + timer._frameLeftover = 0; + app.accumulator = 0; + app.accumulatorMax = app.stepSize * 10; + app.lastUpdateDelta = 0; + let updates = 0; + const worldUpdate = app.world.update; + app.world.update = () => { + updates++; + return false; + }; + const counts = []; + const reported = []; + let time = timer.now; + try { + for (const delta of deltas) { + time += delta; + const before = updates; + app.update(time); + counts.push(updates - before); + reported.push(timer.getDelta()); + } + } finally { + app.world.update = worldUpdate; + timer.syncToDisplay = false; + } + return { counts, reported, updates }; + }; + + const tally = (counts) => { + const out = {}; + for (const count of counts) { + out[count] = (out[count] ?? 0) + 1; + } + return out; + }; + + describe("the setting", () => { + it("is off by default", async () => { + const other = new Application(64, 64, { renderer: video.CANVAS }); + try { + timer.syncToDisplay = true; + await other.init(); + expect(other.settings.syncToDisplay).toBe(false); + expect(timer.syncToDisplay).toBe(false); + } finally { + other.destroy(); + } + }); + + it("is handed to the timer when the application is initialized", async () => { + const other = new Application(64, 64, { + renderer: video.CANVAS, + syncToDisplay: true, + }); + try { + timer.syncToDisplay = false; + await other.init(); + expect(timer.syncToDisplay).toBe(true); + } finally { + other.destroy(); + timer.syncToDisplay = false; + } + }); + }); + + describe("on a display as fast as the updates", () => { + // 59.94 Hz: a common refresh, a little slower than the 60 updates + const frames = display(59.94, 1200, 1); + + it("runs one update on every frame", () => { + const { counts } = run(frames, true); + expect(tally(counts)).toEqual({ 1: 1200 }); + }); + + it("runs none on some frames and two on others without it", () => { + // the stutter itself: the same frames, counted as they come + const { counts } = run(frames, false); + const seen = tally(counts); + expect(seen[0]).toBeGreaterThan(0); + expect(seen[2]).toBeGreaterThan(0); + }); + + it("reports every frame as exactly one step", () => { + const { reported } = run(frames, true); + for (const delta of reported) { + expect(delta).toBe(timer.step); + } + }); + + it("reports the frame time as measured without it", () => { + const { reported } = run(frames, false); + for (let i = 0; i < frames.length; i++) { + expect(reported[i]).toBeCloseTo(frames[i], 6); + } + }); + }); + + describe("on a display twice as fast as the updates", () => { + it("runs one update every other frame, once it has learned the display", () => { + // 119.88 Hz, its frames within half a millisecond + const { counts } = run(display(119.88, 1600, 0.5), true); + const settled = counts.slice(400); + for (let i = 1; i < settled.length; i++) { + expect(settled[i] + settled[i - 1]).toBe(1); + } + }); + }); + + describe("a dropped frame", () => { + it("counts as two, and leaves the frames after it at one", () => { + // 60 Hz, missing its 300th refresh + const { counts, reported } = run(display(60, 600, 1, 11, [300]), true); + // the refreshes before it are frames 0 to 298, so frame 299 spans two + expect(counts[299]).toBe(2); + expect(reported[299]).toBe(timer.step * 2); + for (let i = 300; i < counts.length; i++) { + expect(counts[i]).toBe(1); + expect(reported[i]).toBe(timer.step); + } + }); + }); + + describe("whatever the time carried when the display settles", () => { + // a few odd frames first leave the carry anywhere within a frame; + // the jitter must not tip the count, wherever it is + it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])("seed %s", (seed) => { + const start = [3 + seed * 1.7, 9 + seed * 0.9, 5.5]; + const at60 = run([...start, ...display(59.94, 900, 1, seed)], true); + expect(tally(at60.counts.slice(start.length + 60))).toEqual({ + 1: 900 - 60, + }); + const at120 = run( + [...start, ...display(119.88, 1200, 0.5, seed)], + true, + ).counts.slice(start.length + 300); + for (let i = 1; i < at120.length; i++) { + expect(at120[i] + at120[i - 1]).toBe(1); + } + }); + }); + + describe("a display whose rate changes", () => { + it("is learned again when it slows down, as on another screen", () => { + // ten seconds at 144 Hz, then 60 Hz: a few frames to tell it from + // dropped ones, then one step a frame + const fast = display(144, 1440, 0.3); + const slow = display(60, 1200, 1, 3); + const { counts, reported } = run([...fast, ...slow], true); + const settled = fast.length + 30; + for (const delta of reported.slice(settled)) { + expect(delta).toBe(timer.step); + } + expect(tally(counts.slice(settled))).toEqual({ 1: 1170 }); + }); + + it("is learned again when it speeds up", () => { + const slow = display(60, 600, 1); + const fast = display(119.88, 1600, 0.5, 5); + const { counts } = run([...slow, ...fast], true); + const settled = counts.slice(slow.length + 400); + for (let i = 1; i < settled.length; i++) { + expect(settled[i] + settled[i - 1]).toBe(1); + } + }); + + it("is learned from the display when maxfps is faster than it", () => { + // the learning starts from maxfps: 120 Hz, on a 60 Hz display + timer.maxfps = 120; + timer.reset(); + app.updateFrameRate(); + try { + const { counts, reported } = run(display(60, 600, 1), true); + for (const delta of reported.slice(30)) { + expect(delta).toBe(timer.step * 2); + } + expect(tally(counts.slice(30))).toEqual({ 1: 570 }); + } finally { + timer.maxfps = 60; + timer.reset(); + app.updateFrameRate(); + } + }); + }); + + describe("a timer reset", () => { + // a state change, a resume or a restart resets the timer: the frame + // after it is a partial one, as short as the time left to the refresh + const runWithResets = (deltas, resets) => { + timer.syncToDisplay = true; + timer._displayFrame = 0; + timer._frameLeftover = 0; + app.accumulator = 0; + app.lastUpdateDelta = 0; + let updates = 0; + const worldUpdate = app.world.update; + app.world.update = () => { + updates++; + return false; + }; + const counts = []; + let time = timer.now; + try { + deltas.forEach((delta, i) => { + if (resets.has(i)) { + timer.reset(); + timer.last = timer.now = time; + delta = 5; + } + time += delta; + const before = updates; + app.update(time); + counts.push(updates - before); + }); + } finally { + app.world.update = worldUpdate; + timer.syncToDisplay = false; + } + return counts; + }; + + it("leaves the frames after its partial one at one update each", () => { + const resets = new Set(); + for (let i = 100; i < 2000; i += 97) { + resets.add(i); + } + const counts = runWithResets(display(60, 2000, 1), resets); + const off = []; + counts.forEach((count, i) => { + if (i >= 60 && !resets.has(i) && count !== 1) { + off.push(`${i}: ${count}`); + } + }); + expect(off).toEqual([]); + }); + + it("drops the time carried to the next frame", () => { + run(display(60, 200, 1), true); + timer._frameLeftover = 7; + timer.reset(); + expect(timer._frameLeftover).toBe(0); + }); + }); + + describe("a maxfps above 120", () => { + it.each([144, 165, 240])("maxfps %s still updates the game", (maxfps) => { + // an update every `frameRate` frames, which rounded down to 0 + // above 120: the game never updated + timer.maxfps = maxfps; + timer.reset(); + app.updateFrameRate(); + try { + expect(app.frameRate).toBe(1); + const { updates } = run(display(60, 120, 0.2), false); + expect(updates).toBeGreaterThan(110); + } finally { + timer.maxfps = 60; + timer.reset(); + app.updateFrameRate(); + } + }); + }); + + describe("the rates counted exactly", () => { + // a quarter of, a third of, half, twice, three and four times maxfps + it.each([ + [15, 4], + [20, 3], + [30, 2], + [120, 1 / 2], + [180, 1 / 3], + [240, 1 / 4], + ])("%s Hz: every frame counted as %s step", (hz, steps) => { + const { reported } = run(display(hz, 900, 0.2), true); + for (const delta of reported.slice(300)) { + expect(delta).toBe(timer.step * steps); + } + }); + }); + + describe("frame times that match no multiple of a step", () => { + // the updates the time holds: what has not run yet is still in the + // accumulator (under a step) or carried by the timer (under half a + // display frame), so the count is within two of it + const expected = (deltas) => { + return ( + deltas.reduce((sum, delta) => { + return sum + delta; + }, 0) / timer.step + ); + }; + + it("keep the game's speed on a 144 Hz display", () => { + const deltas = display(144, 1440, 0.3); + const { updates } = run(deltas, true); + expect(Math.abs(updates - expected(deltas))).toBeLessThan(2); + }); + + it.each([ + ["rounded up", 34], + ["rounded down", 26], + ])( + "keep the game's speed when they are irregular, the long ones %s", + (_how, long) => { + // three short frames and a long one, over and over: no display + // rate, so every frame is rounded, and what is rounded off has to + // be carried for the total to come out right + const deltas = []; + while (deltas.length < 600) { + deltas.push(12, 12, 12, long); + } + const { updates, reported } = run(deltas, true); + expect(Math.abs(updates - expected(deltas))).toBeLessThan(2); + const real = deltas.reduce((sum, delta) => { + return sum + delta; + }, 0); + const counted = reported.reduce((sum, delta) => { + return sum + delta; + }, 0); + expect(Math.abs(counted - real)).toBeLessThan(timer.step); + }, + ); + }); +}); diff --git a/packages/melonjs/tests/texture-mipmap.spec.js b/packages/melonjs/tests/texture-mipmap.spec.js new file mode 100644 index 0000000000..9e11496a88 --- /dev/null +++ b/packages/melonjs/tests/texture-mipmap.spec.js @@ -0,0 +1,601 @@ +/** + * The `"linear-mipmap"` texture filter: what is drawn smaller than its + * texture takes its colour from the mipmaps, and nothing else changes. + * + * Read in pixels on WebGL, with a checkerboard of single black and white + * texels: drawn small, it averages to grey from the mipmaps, while a plain + * linear sample picks out single texels and comes out patchy. Drawn at its + * size or larger, the two filters must give the same pixels. + */ +import "./helpers/webgpu-globals.js"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vitest"; +import { boot, Color } from "../src/index.js"; +import Renderer from "../src/video/renderer.js"; +import { createAtlas, TextureAtlas } from "../src/video/texture/atlas.js"; +import WebGPULitQuadBatcher from "../src/video/webgpu/batchers/lit_quad_batcher.js"; +import WebGPUQuadBatcher from "../src/video/webgpu/batchers/quad_batcher.js"; +import WebGPUTMXRenderer from "../src/video/webgpu/renderers/tmxlayer/orthogonal.js"; +import tmxlayerWGSL from "../src/video/webgpu/shaders/tmxlayer.wgsl"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +const SIZE = 64; + +// a checkerboard of single texels, `size` square, at (x, y) of `ctx` +const checker = (ctx, x, y, size) => { + for (let j = 0; j < size; j++) { + for (let i = 0; i < size; i++) { + ctx.fillStyle = (i + j) % 2 === 0 ? "#000000" : "#ffffff"; + ctx.fillRect(x + i, y + j, 1, 1); + } + } +}; + +describe("linear-mipmap texture filter (WebGL)", () => { + let renderer; + let initialFilter; + let board; + + beforeAll(async () => { + await boot(); + try { + renderer = await getWebGLRenderer(SIZE, SIZE); + } catch { + // no WebGL here: the tests below mark themselves skipped + } + if (renderer) { + renderer.projectionMatrix.ortho(0, SIZE, SIZE, 0, -1000, 1000); + renderer.currentBatcher.setProjection(renderer.projectionMatrix); + initialFilter = renderer.settings.textureFilter; + } + board = Renderer.createCanvas(64, 64); + checker(board.getContext("2d"), 0, 0, 64); + }); + + afterEach(() => { + renderer?.setTextureFilter(initialFilter); + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + // the whole frame, top row first, as [r, g, b] per pixel + const frame = () => { + const gl = renderer.gl; + const out = new Uint8Array(SIZE * SIZE * 4); + gl.readPixels(0, 0, SIZE, SIZE, gl.RGBA, gl.UNSIGNED_BYTE, out); + return (x, y) => { + const i = ((SIZE - 1 - y) * SIZE + x) * 4; + return [out[i], out[i + 1], out[i + 2]]; + }; + }; + + // the board drawn at (x, y), w x h, under `filter` + const drawBoard = (filter, x, y, w, h) => { + renderer.setTextureFilter(filter); + renderer.clearColor("#000000ff"); + renderer.drawImage(board, 0, 0, 64, 64, x, y, w, h); + renderer.flush(); + return frame(); + }; + + // the pixels of a square, `from` to `to` on both axes + const square = (at, from, to) => { + const out = []; + for (let y = from; y <= to; y++) { + for (let x = from; x <= to; x++) { + out.push(at(x, y)); + } + } + return out; + }; + + describe("a texture drawn smaller than its size", () => { + // a third of its size, from a whole pixel (where the renderer puts + // it): every pixel then samples a texel's centre, so a plain linear + // sample is a single texel + const SMALL = [2, 2, 64 / 3, 64 / 3]; + + it("is smoothed by its mipmaps", (ctx) => { + requireWebGL(ctx); + const at = drawBoard("linear-mipmap", ...SMALL); + for (const [r, g, b] of square(at, 4, 20)) { + expect(Math.abs(r - 128)).toBeLessThan(32); + expect(g).toBe(r); + expect(b).toBe(r); + } + }); + + it("is patchy under plain linear filtering", (ctx) => { + // what the mipmaps fix: single texels picked out of the board + requireWebGL(ctx); + const at = drawBoard("linear", ...SMALL); + const off = square(at, 4, 20).map(([r]) => { + return Math.abs(r - 128); + }); + expect(Math.max(...off)).toBeGreaterThan(64); + }); + }); + + describe("a texture drawn at its size or larger", () => { + for (const [name, place] of [ + ["at its size", [0, 0, 64, 64]], + ["at its size, between pixels", [3.4, 5.7, 64, 64]], + ["at twice its size", [-20.5, -9.25, 128, 128]], + ]) { + it(`is drawn ${name} as plain linear filtering draws it`, (ctx) => { + requireWebGL(ctx); + const linear = drawBoard("linear", ...place); + const mipmap = drawBoard("linear-mipmap", ...place); + for (let y = 0; y < SIZE; y++) { + for (let x = 0; x < SIZE; x++) { + expect(mipmap(x, y)).toEqual(linear(x, y)); + } + } + }); + } + }); + + describe("which textures sample their mipmaps", () => { + // a texture drawing `source` + const as = (source) => { + return { + getTexture() { + return source; + }, + }; + }; + + it("none, unless the filter is linear-mipmap", (ctx) => { + requireWebGL(ctx); + for (const filter of ["auto", "nearest", "linear"]) { + renderer.setTextureFilter(filter); + expect(renderer.wantsMipmaps(as(board))).toBe(false); + } + renderer.setTextureFilter("linear-mipmap"); + expect(renderer.wantsMipmaps(as(board))).toBe(true); + }); + + it("not a video, which would rebuild them every frame", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("linear-mipmap"); + const video = document.createElement("video"); + expect(renderer.wantsMipmaps(as(video))).toBe(false); + }); + + it("not a texture the engine renders into, whose levels are never filled", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("linear-mipmap"); + const resource = { width: 4, height: 4, upload() {} }; + expect(renderer.wantsMipmaps(as(resource))).toBe(false); + }); + + it("resolves linear-mipmap to linear for everything else", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("linear-mipmap"); + expect(renderer.getDefaultTextureFilter()).toBe("linear"); + }); + }); + + describe("an orthogonal tile layer drawn by the GPU", () => { + // the tileset: tile 1 a checkerboard, tile 2 solid blue beside it + const TILE = 32; + let tileset; + + beforeAll(() => { + const image = Renderer.createCanvas(TILE * 2, TILE); + const ctx = image.getContext("2d"); + checker(ctx, 0, 0, TILE); + ctx.fillStyle = "#0000ff"; + ctx.fillRect(TILE, 0, TILE, TILE); + tileset = { + tilewidth: TILE, + tileheight: TILE, + margin: 0, + spacing: 0, + image, + firstgid: 1, + lastgid: 2, + isCollection: false, + isAnimated: false, + animations: new Map(), + texture: new TextureAtlas( + createAtlas(image.width, image.height, "tileset", "no-repeat"), + image, + ), + }; + }); + + // a map of tile 1 only, drawn at `scale` from (x, y). Seven cells a + // side, not a power of two: the shader's derivatives then carry a + // rounding error, as in a real map + const drawLayer = (filter, scale, x, y, set = tileset, gid = 1) => { + const cols = 7; + const rows = 7; + const layerData = new Uint16Array(cols * rows * 2); + for (let i = 0; i < cols * rows; i++) { + layerData[i * 2] = gid; + } + const layer = { + cols, + rows, + tilewidth: TILE, + tileheight: TILE, + layerData, + dataVersion: 0, + tilesets: { tilesets: [set] }, + getOpacity() { + return 1; + }, + }; + renderer.setTextureFilter(filter); + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.translate(x, y); + renderer.scale(scale, scale); + renderer + ._getTMXGPURendererFor("orthogonal") + .draw(layer, { pos: { x: 0, y: 0 }, width: 224, height: 224 }); + renderer.flush(); + renderer.restore(); + return frame(); + }; + + it("is smoothed by its mipmaps, without the next tile bleeding in", (ctx) => { + // 0.3 of the size, off the pixel grid: the tile edges, every 9.6 + // pixels, fall at every phase of the pixels and of the levels + requireWebGL(ctx); + const at = drawLayer("linear-mipmap", 0.3, 1.3, 1.7); + for (const [r, g, b] of square(at, 3, 60)) { + expect(Math.abs(r - 128)).toBeLessThan(32); + expect(g).toBe(r); + // blue from the neighbouring tile of the atlas + expect(b - r).toBeLessThanOrEqual(2); + } + }); + + it("is patchy under plain linear filtering", (ctx) => { + requireWebGL(ctx); + const at = drawLayer("linear", 0.3, 1.3, 1.7); + const off = square(at, 3, 60).map(([r]) => { + return Math.abs(r - 128); + }); + expect(Math.max(...off)).toBeGreaterThan(64); + }); + + describe("faded or tinted", () => { + // a white tile over blue: the pixel shows the layer's strength, + // and how much of what is behind it shows through + let white; + beforeAll(() => { + const image = Renderer.createCanvas(TILE, TILE); + const ctx = image.getContext("2d"); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, TILE, TILE); + white = { + ...tileset, + image, + lastgid: 1, + texture: new TextureAtlas( + createAtlas(TILE, TILE, "white", "no-repeat"), + image, + ), + }; + }); + + // `alpha` is the renderer's global alpha, which a layer's preDraw + // sets to its opacity times the fade of everything it is drawn in + const drawWhite = (alpha, tint, opacity = 1) => { + const layerData = new Uint16Array(4 * 2); + for (let i = 0; i < 4; i++) { + layerData[i * 2] = 1; + } + renderer.setTextureFilter("nearest"); + renderer.clearColor("#0000ffff"); + renderer.save(); + renderer.setGlobalAlpha(alpha); + try { + renderer._getTMXGPURendererFor("orthogonal").draw( + { + cols: 2, + rows: 2, + tilewidth: TILE, + tileheight: TILE, + layerData, + dataVersion: 0, + tint, + tilesets: { tilesets: [white] }, + getOpacity() { + return opacity; + }, + }, + { pos: { x: 0, y: 0 }, width: 64, height: 64 }, + ); + renderer.flush(); + } finally { + renderer.restore(); + } + return frame()(20, 20); + }; + + const near = (actual, expected) => { + for (let i = 0; i < 3; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(2); + } + }; + + it("is drawn as strong as its fade, what is behind it showing through", (ctx) => { + requireWebGL(ctx); + // half white over blue + near(drawWhite(0.5, null), [128, 128, 255]); + }); + + it("takes its fade from the renderer, which holds its container's too", (ctx) => { + // the layer's own opacity was already folded in by its preDraw: + // it is not applied a second time, nor in place of the fade + requireWebGL(ctx); + near(drawWhite(0.5, null, 0.25), [128, 128, 255]); + near(drawWhite(1, null, 0.25), [255, 255, 255]); + }); + + it("CONTROL: is drawn white at full strength", (ctx) => { + requireWebGL(ctx); + expect(drawWhite(1, null)).toEqual([255, 255, 255]); + }); + + it("takes its tint's color, not its alpha, as a sprite does", (ctx) => { + requireWebGL(ctx); + expect(drawWhite(1, new Color(255, 0, 0, 0.5))).toEqual([255, 0, 0]); + near(drawWhite(0.5, new Color(255, 0, 0, 0.5)), [128, 0, 128]); + }); + }); + + it("keeps each tile to itself when drawn larger under plain linear filtering", (ctx) => { + // 1.5 times its size: the first tile's right edge, at x 48, is + // where the atlas goes on into the blue tile. Filtering within + // half a texel of that edge took it in: a blue seam between two + // tiles that both draw the checkerboard + requireWebGL(ctx); + const at = drawLayer("linear", 1.5, 0.4, 0.6); + let bluest = 0; + for (let y = 0; y < SIZE; y++) { + for (let x = 0; x < SIZE; x++) { + const [r, g, b] = at(x, y); + expect(g).toBe(r); + bluest = Math.max(bluest, b - r); + } + } + expect(bluest).toBeLessThanOrEqual(2); + }); + + it("keeps a tile to itself on every side, the atlas around it on all four", (ctx) => { + // a 3x3 tileset: the checkerboard in the middle (tile 5), blue + // all around it, so each of its edges has a neighbour + requireWebGL(ctx); + const image = Renderer.createCanvas(TILE * 3, TILE * 3); + const g = image.getContext("2d"); + g.fillStyle = "#0000ff"; + g.fillRect(0, 0, TILE * 3, TILE * 3); + checker(g, TILE, TILE, TILE); + const surrounded = { + ...tileset, + image, + lastgid: 9, + texture: new TextureAtlas( + createAtlas(image.width, image.height, "surrounded", "no-repeat"), + image, + ), + }; + const at = drawLayer("linear", 1.5, 0.4, 0.6, surrounded, 5); + let bluest = 0; + for (let y = 0; y < SIZE; y++) { + for (let x = 0; x < SIZE; x++) { + const [r, g2, b] = at(x, y); + expect(g2).toBe(r); + bluest = Math.max(bluest, b - r); + } + } + expect(bluest).toBeLessThanOrEqual(2); + }); + + it("is drawn at its size as plain linear filtering draws it", (ctx) => { + requireWebGL(ctx); + const linear = drawLayer("linear", 1, 0.4, 0.6); + const mipmap = drawLayer("linear-mipmap", 1, 0.4, 0.6); + for (let y = 0; y < SIZE; y++) { + for (let x = 0; x < SIZE; x++) { + expect(mipmap(x, y)).toEqual(linear(x, y)); + } + } + }); + }); +}); + +/** + * The same choices on WebGPU, against the mock renderer (headless Chromium + * has no WebGPU device): what each draw asks the texture store for. The + * store builds a chain only for a draw that asks, and samples it only with + * a mip sampler, so asking for both is the whole of the change there. + */ +describe("linear-mipmap texture filter (WebGPU, mock renderer)", () => { + let renderer; + let batcher; + + // a texture drawing `source`, with any other fields given + const atlas = (fields = {}, source = { width: 64, height: 64 }) => { + return { + ...fields, + getTexture() { + return source; + }, + }; + }; + + beforeEach(() => { + renderer = createMockWebGPURenderer(); + batcher = new WebGPUQuadBatcher(renderer); + }); + + // what a batched sprite drawn from `texture` asked for, and was given + const sprite = (filter, texture) => { + renderer.settings.textureFilter = filter; + const slot = batcher.segmentSlotFor(texture, false); + return { + chain: renderer.textureStore.residentOptions.at(-1).options.mipmaps, + sampler: batcher.segmentEntries[slot].sampler, + }; + }; + + it("a sprite asks for the chain and samples it", () => { + const { chain, sampler } = sprite("linear-mipmap", atlas()); + expect(chain).toBe(true); + expect(sampler.mipmaps).toBe(true); + }); + + it("a sprite keeps to level 0 under plain linear filtering", () => { + const { chain, sampler } = sprite("linear", atlas()); + expect(chain).toBe(false); + expect(sampler.mipmaps).toBe(false); + }); + + it("a nearest sprite keeps to level 0", () => { + const { chain, sampler } = sprite( + "linear-mipmap", + atlas({ filter: "nearest" }), + ); + expect(chain).toBe(false); + expect(sampler.mipmaps).toBe(false); + }); + + it("a video keeps to level 0", () => { + const { chain, sampler } = sprite( + "linear-mipmap", + atlas({}, { width: 64, height: 64, videoWidth: 64, videoHeight: 64 }), + ); + expect(chain).toBe(false); + expect(sampler.mipmaps).toBe(false); + }); + + it("a sprite drawn through an effect asks for the chain", () => { + renderer.settings.textureFilter = "linear-mipmap"; + renderer.customShader = {}; + batcher.addQuad(atlas(), 0, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + expect(renderer.calls.textureBindings.at(-1).options.mipmaps).toBe(true); + }); + + it("a lit sprite samples the chain for its color, not its normal map", () => { + renderer.settings.textureFilter = "linear-mipmap"; + const lit = new WebGPULitQuadBatcher(renderer); + lit.addQuad(atlas(), 0, 0, 32, 32, 0, 0, 1, 1, 0xffffffff, false, { + width: 4, + height: 4, + }); + const [, colorSampler, , normalSampler] = lit.currentMaterial.entries; + expect(colorSampler.resource.mipmaps).toBe(true); + expect(normalSampler.resource.mipmaps).toBe(false); + }); + + describe("a tile layer", () => { + const layer = (texture) => { + return { + cols: 4, + rows: 4, + tilewidth: 16, + tileheight: 16, + dataVersion: 1, + layerData: new Uint16Array(32), + tint: null, + getOpacity() { + return 1; + }, + tilesets: { + tilesets: [ + { + isCollection: false, + image: { width: 64, height: 64 }, + tilewidth: 16, + tileheight: 16, + margin: 0, + spacing: 0, + firstgid: 1, + lastgid: 16, + isAnimated: false, + animations: new Map(), + texture, + }, + ], + }, + }; + }; + + // the tileset binding's options and the shader's mipmap flag + const tiles = (filter) => { + renderer.settings.textureFilter = filter; + renderer.setBatcher = () => { + return batcher; + }; + const tmx = new WebGPUTMXRenderer(renderer); + tmx.draw(layer(atlas()), { + pos: { x: 0, y: 0 }, + width: 64, + height: 64, + }); + // the per-tileset uniforms: the last 128-byte write + const uniforms = renderer.calls.writes.findLast((write) => { + return write.size === 128; + }); + return { + chain: renderer.calls.textureBindings.at(-1).options?.mipmaps, + flag: uniforms.floats[28], + }; + }; + + it("asks for the chain and sets the shader's mipmap flag", () => { + expect(tiles("linear-mipmap")).toEqual({ chain: true, flag: 1 }); + }); + + it("does neither under plain linear filtering", () => { + expect(tiles("linear")).toEqual({ chain: false, flag: 0 }); + }); + + it("fades its premultiplied texels, color and alpha alike, and tints them", () => { + // the WGSL twin of the WebGL pixel tests above + const code = tmxlayerWGSL.replace(/\/\/.*$/gm, ""); + expect(code).toContain( + "return texel * tmx.uOpacity * vec4f(tmx.uTint.rgb, 1.0);", + ); + expect(code.match(/layerColor\(result\.color\)/g)).toHaveLength(2); + expect(code).not.toMatch(/result\.color\.a \* tmx\.uOpacity/); + }); + + it("keeps each sample half a texel inside its tile, mipmaps or not", () => { + // the level read, 0 without mipmaps: the WGSL twin of what the + // WebGL pixel test above measures + const code = tmxlayerWGSL.replace(/\/\/.*$/gm, ""); + expect(code).toContain( + "let inset = min(vec2f(0.5 * exp2(lod * tmx.uMipmaps)), tmx.uTileSize * 0.5);", + ); + expect(code).toContain( + "texelPx = clamp(texelPx, tileOriginPx + inset, tileOriginPx + tmx.uTileSize - inset);", + ); + }); + }); +}); diff --git a/packages/melonjs/tests/tmx-transparent-color.spec.js b/packages/melonjs/tests/tmx-transparent-color.spec.js new file mode 100644 index 0000000000..646306aaa0 --- /dev/null +++ b/packages/melonjs/tests/tmx-transparent-color.spec.js @@ -0,0 +1,297 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + Application, + boot, + ImageLayer, + TMXTileMap, + video, +} from "../src/index.js"; +import { applyTransparentColor, parse } from "../src/level/tiled/TMXUtils.js"; +import { imgList } from "../src/loader/cache.js"; + +/** + * Tiled's transparent color: an `` may name one color (`trans` in + * TMX, `transparentcolor` in JSON) that Tiled draws as transparent, the + * color key of tilesets that have no alpha channel. Every pixel of exactly + * that color is made transparent, once, on a copy of the image. + */ + +// a 6x1 image: magenta, then magenta off by one in red, red, magenta again, +// then magenta off by one in green, then in blue +const COLORS = [ + "#ff00ff", + "#fe00ff", + "#ff0000", + "#ff00ff", + "#ff01ff", + "#ff00fe", +]; +const keyed = (name) => { + const canvas = document.createElement("canvas"); + canvas.width = COLORS.length; + canvas.height = 1; + const ctx = canvas.getContext("2d"); + COLORS.forEach((color, x) => { + ctx.fillStyle = color; + ctx.fillRect(x, 0, 1, 1); + }); + if (name) { + imgList[name] = canvas; + } + return canvas; +}; + +// magenta keyed out: exactly the two magenta pixels, no near one +const MAGENTA_OUT = [0, 255, 255, 0, 255, 255]; +// red keyed out +const RED_OUT = [255, 255, 0, 255, 255, 255]; + +// the alpha of each pixel of an image's first row +const alphas = (image) => { + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0); + const data = ctx.getImageData(0, 0, image.width, 1).data; + const out = []; + for (let i = 3; i < data.length; i += 4) { + out.push(data[i]); + } + return out; +}; + +describe("Tiled transparent color", () => { + describe("applyTransparentColor", () => { + it("makes every pixel of exactly that color transparent, on a copy", () => { + const image = keyed(); + const result = applyTransparentColor(image, "#ff00ff"); + expect(result).not.toBe(image); + expect(alphas(result)).toEqual(MAGENTA_OUT); + // the image itself is left as it was + expect(alphas(image)).toEqual([255, 255, 255, 255, 255, 255]); + }); + + it.each([["ff00ff"], ["#FF00FF"], ["#80ff00ff"]])( + "reads %s as magenta", + (color) => { + expect(alphas(applyTransparentColor(keyed(), color))).toEqual( + MAGENTA_OUT, + ); + }, + ); + + it("is worked out once per image and color", () => { + const image = keyed(); + const first = applyTransparentColor(image, "#ff00ff"); + expect(applyTransparentColor(image, "ff00ff")).toBe(first); + const red = applyTransparentColor(image, "#ff0000"); + expect(red).not.toBe(first); + expect(alphas(red)).toEqual(RED_OUT); + }); + + it.each([[undefined], [""], ["#ff00"], ["not a color"]])( + "leaves the image as it is with %s", + (color) => { + const image = keyed(); + expect(applyTransparentColor(image, color)).toBe(image); + }, + ); + + it("draws an image it may not read as it is, and says so once", () => { + // a cross-origin image without CORS taints the canvas it is drawn on + const read = vi + .spyOn(CanvasRenderingContext2D.prototype, "getImageData") + .mockImplementation(() => { + throw new DOMException("tainted", "SecurityError"); + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const first = keyed(); + const second = keyed(); + expect(applyTransparentColor(first, "#ff00ff")).toBe(first); + expect(applyTransparentColor(second, "#ff00ff")).toBe(second); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + read.mockRestore(); + warn.mockRestore(); + } + }); + + it("draws an image it cannot draw on a canvas as it is", () => { + // a compressed texture: the canvas refuses to draw it + const compressed = { width: 4, height: 1, isCompressed: true }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(applyTransparentColor(compressed, "#ff00ff")).toBe(compressed); + } finally { + warn.mockRestore(); + } + }); + }); + + describe("read from a TMX file", () => { + const xml = (source) => { + return parse( + new DOMParser().parseFromString(source, "text/xml").documentElement, + ); + }; + + it("keeps a tileset image's trans", () => { + const map = + xml(` + + + + `); + expect(map.tilesets[0].transparentcolor).toBe("#ff00ff"); + expect(map.tilesets[0].image).toBe("t.png"); + }); + + it("keeps a collection tile's trans", () => { + const map = + xml(` + + + + `); + expect(map.tilesets[0].tiles[0].transparentcolor).toBe("#00ff00"); + }); + + it("keeps an image layer's trans", () => { + const map = + xml(` + + `); + expect(map.layers[0].transparentcolor).toBe("#ff00ff"); + expect(map.layers[0].image).toBe("sky.png"); + }); + + it("adds none where the image has no trans", () => { + const map = + xml(` + + + + `); + expect(map.tilesets[0].transparentcolor).toBeUndefined(); + }); + }); + + describe("applied to a map", () => { + let app; + + beforeAll(async () => { + await boot(); + // tilesets build their atlas through the game's renderer + app = new Application(64, 64, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + keyed("trans-sheet"); + keyed("trans-tile"); + keyed("trans-sky"); + }); + + afterAll(() => { + app?.destroy(); + }); + + const mapOf = (tileset, layers = []) => { + const map = new TMXTileMap(`trans-${Math.random()}`, { + width: 1, + height: 1, + tilewidth: 2, + tileheight: 1, + orientation: "orthogonal", + renderorder: "right-down", + infinite: false, + version: "1.10", + tiledversion: "1.12.0", + tilesets: tileset ? [tileset] : [], + layers, + }); + // reads the tilesets and builds the layers + map.getLayers(); + return map; + }; + + it("draws a tileset with its color transparent", () => { + const map = mapOf({ + firstgid: 1, + name: "sheet", + tilewidth: 2, + tileheight: 1, + tilecount: 2, + columns: 2, + spacing: 0, + margin: 0, + image: "trans-sheet.png", + transparentcolor: "#ff00ff", + }); + const tileset = map.tilesets.getTilesetByIndex(0); + expect(tileset.image).not.toBe(imgList["trans-sheet"]); + expect(alphas(tileset.image)).toEqual(MAGENTA_OUT); + // the atlas the tiles are drawn from is the keyed image + expect(tileset.texture.getTexture()).toBe(tileset.image); + }); + + it("leaves a tileset with no transparent color as it is", () => { + const map = mapOf({ + firstgid: 1, + name: "plain", + tilewidth: 2, + tileheight: 1, + tilecount: 2, + columns: 2, + spacing: 0, + margin: 0, + image: "trans-sheet.png", + }); + expect(map.tilesets.getTilesetByIndex(0).image).toBe( + imgList["trans-sheet"], + ); + }); + + it("draws a collection tile with its color transparent", () => { + const map = mapOf({ + firstgid: 1, + name: "collection", + tilewidth: 4, + tileheight: 1, + tilecount: 1, + columns: 0, + tiles: [ + { + id: 0, + image: "trans-tile.png", + imagewidth: 4, + imageheight: 1, + transparentcolor: "#ff0000", + }, + ], + }); + const image = map.tilesets.getTilesetByIndex(0).imageCollection.get(1); + expect(alphas(image)).toEqual(RED_OUT); + }); + + it("draws an image layer with its color transparent", () => { + const map = mapOf(null, [ + { + type: "imagelayer", + name: "sky", + image: "trans-sky.png", + transparentcolor: "#ff00ff", + visible: true, + opacity: 1, + }, + ]); + const layer = map.getLayers().find((l) => { + return l instanceof ImageLayer; + }); + expect(alphas(layer.image)).toEqual(MAGENTA_OUT); + }); + }); +}); diff --git a/packages/melonjs/tests/tmxlayer-drawraw.spec.js b/packages/melonjs/tests/tmxlayer-drawraw.spec.js index 4f82420d17..7c83cf498c 100644 --- a/packages/melonjs/tests/tmxlayer-drawraw.spec.js +++ b/packages/melonjs/tests/tmxlayer-drawraw.spec.js @@ -84,7 +84,6 @@ function makeRecordingRenderer() { const transformStack = []; let current = { tx: 0, ty: 0 }; return { - uvOffset: 0, drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh) { calls.push({ image, @@ -247,7 +246,6 @@ describe("Tile rendering raw path (drawTileRaw)", () => { let saved = 0; let restored = 0; const renderer = { - uvOffset: 0, drawImage() {}, save() { saved++; @@ -269,7 +267,6 @@ describe("Tile rendering raw path (drawTileRaw)", () => { let saved = 0; let restored = 0; const renderer = { - uvOffset: 0, drawImage() {}, save() { saved++; diff --git a/packages/melonjs/tests/tmxlayer-prerender.spec.js b/packages/melonjs/tests/tmxlayer-prerender.spec.js new file mode 100644 index 0000000000..45f964aab7 --- /dev/null +++ b/packages/melonjs/tests/tmxlayer-prerender.spec.js @@ -0,0 +1,558 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { + Application, + boot, + Color, + event, + Rect, + TMXTileMap, + video, +} from "../src/index.js"; +import Tile from "../src/level/tiled/TMXTile.js"; +import { imgList } from "../src/loader/cache.js"; +import CanvasRenderer from "../src/video/canvas/canvas_renderer.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; + +/** + * A pre-rendered tile layer is baked once into an offscreen canvas and drawn + * as one image. The bake must hold every tile the layer draws: on hexagonal, + * staggered and oblique maps the layer reaches past `cols * tilewidth` + * (an oblique one even left of 0), and an oversized or offset tile reaches + * past its cell. A tile set or cleared at runtime must show in it too. + * + * Each case draws the layer tile by tile and from its bake, onto canvases of + * the same size, and compares every pixel. + */ + +// a tileset image of two opaque tiles, red then blue, `w` x `h` each +const tilesetImage = (name, w, h) => { + const canvas = document.createElement("canvas"); + canvas.width = w * 2; + canvas.height = h; + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#ff0000"; + ctx.fillRect(0, 0, w, h); + ctx.fillStyle = "#0000ff"; + ctx.fillRect(w, 0, w, h); + // a dark edge, so a tile cut short shows + ctx.strokeStyle = "#000000"; + ctx.strokeRect(0.5, 0.5, w - 1, h - 1); + ctx.strokeRect(w + 0.5, 0.5, w - 1, h - 1); + imgList[name] = canvas; + return `${name}.png`; +}; + +const COLS = 5; +const ROWS = 4; + +/** + * a map of one tile layer, every cell filled, its tiles taken in turn from + * each tileset given (two tiles each) + * @param {object} map - the orientation's map attributes + * @param {...object} tilesets - each tileset's tile size, offset or render size + * @returns {object} the layer + */ +const layerOf = (map, ...tilesets) => { + const name = `prerender-${map.orientation}-${map.staggeraxis ?? ""}`; + const data = []; + for (let i = 0; i < COLS * ROWS; i++) { + data.push(1 + (i % (tilesets.length * 2))); + } + const tmx = new TMXTileMap(name, { + width: COLS, + height: ROWS, + renderorder: "right-down", + infinite: false, + version: "1.10", + tiledversion: "1.12.0", + ...map, + tilesets: tilesets.map((tileset, i) => { + const image = `${name}-${i}-${tileset.tilewidth}x${tileset.tileheight}`; + return { + firstgid: 1 + i * 2, + name: image, + spacing: 0, + margin: 0, + tilecount: 2, + columns: 2, + image: tilesetImage(image, tileset.tilewidth, tileset.tileheight), + ...tileset, + }; + }), + layers: [ + { + type: "tilelayer", + name: "ground", + width: COLS, + height: ROWS, + data, + visible: true, + opacity: 1, + }, + ], + }); + return tmx.getLayers()[0]; +}; + +const ORIENTATIONS = [ + [ + "orthogonal, tiles taller than their cell", + { orientation: "orthogonal", tilewidth: 32, tileheight: 32 }, + { tilewidth: 32, tileheight: 48 }, + ], + [ + "orthogonal, tiles offset past their cell", + { orientation: "orthogonal", tilewidth: 32, tileheight: 32 }, + { tilewidth: 32, tileheight: 32, tileoffset: { x: 6, y: 10 } }, + ], + [ + "orthogonal, tiles smaller than their cell", + { orientation: "orthogonal", tilewidth: 32, tileheight: 32 }, + { tilewidth: 16, tileheight: 16 }, + ], + [ + "orthogonal, small tiles scaled to their cell", + { orientation: "orthogonal", tilewidth: 32, tileheight: 32 }, + { tilewidth: 16, tileheight: 16, tilerendersize: "grid" }, + ], + [ + "orthogonal, two tilesets offset opposite ways", + { orientation: "orthogonal", tilewidth: 32, tileheight: 32 }, + { tilewidth: 32, tileheight: 32, tileoffset: { x: -6, y: 8 } }, + { tilewidth: 32, tileheight: 32, tileoffset: { x: 10, y: -4 } }, + ], + [ + "isometric, tiles smaller than their cell", + { orientation: "isometric", tilewidth: 32, tileheight: 16 }, + { tilewidth: 16, tileheight: 16 }, + ], + [ + "isometric, tiles taller than their cell", + { orientation: "isometric", tilewidth: 32, tileheight: 16 }, + { tilewidth: 32, tileheight: 32 }, + ], + [ + "isometric, tiles the size of their cell", + { orientation: "isometric", tilewidth: 32, tileheight: 16 }, + { tilewidth: 32, tileheight: 16 }, + ], + [ + "hexagonal, staggered along y, with a tile offset", + { + orientation: "hexagonal", + tilewidth: 14, + tileheight: 12, + hexsidelength: 6, + staggeraxis: "y", + staggerindex: "odd", + }, + { tilewidth: 18, tileheight: 18, tileoffset: { x: 0, y: 1 } }, + ], + [ + "hexagonal, staggered along x", + { + orientation: "hexagonal", + tilewidth: 12, + tileheight: 14, + hexsidelength: 6, + staggeraxis: "x", + staggerindex: "even", + }, + { tilewidth: 18, tileheight: 18 }, + ], + [ + "staggered", + { + orientation: "staggered", + tilewidth: 32, + tileheight: 16, + staggeraxis: "y", + staggerindex: "odd", + }, + { tilewidth: 32, tileheight: 32 }, + ], + [ + "oblique, skewed left of 0", + { orientation: "oblique", tilewidth: 40, tileheight: 14, skewx: -12 }, + { tilewidth: 51, tileheight: 26 }, + ], +]; + +// the layout of that name, as `layerOf` arguments +const layout = (name) => { + return ORIENTATIONS.find(([label]) => { + return label === name; + }).slice(1); +}; + +describe("TMXLayer pre-rendered", () => { + const renderers = []; + let app; + + beforeAll(async () => { + await boot(); + // tilesets build their atlas through the game's renderer + app = new Application(64, 64, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + afterAll(() => { + app?.destroy(); + }); + + afterEach(() => { + for (const renderer of renderers.splice(0)) { + renderer.destroy(); + } + }); + + // a canvas renderer the size of the layer and a margin, drawing it + // with its top left corner at (PAD, PAD) + const PAD = 40; + const target = (layer) => { + const bounds = layer.getRenderer().getBounds(layer); + const area = new Rect( + bounds.left - PAD, + bounds.top - PAD, + bounds.width + PAD * 2, + bounds.height + PAD * 2, + ); + const renderer = new CanvasRenderer({ + width: Math.ceil(area.width), + height: Math.ceil(area.height), + transparent: true, + }); + renderers.push(renderer); + renderer.translate(-area.left, -area.top); + return { renderer, area }; + }; + + const pixels = (renderer) => { + const canvas = renderer.getCanvas(); + return renderer.getContext().getImageData(0, 0, canvas.width, canvas.height) + .data; + }; + + // the layer drawn tile by tile, and from its bake + const both = (layer) => { + const tiles = target(layer); + layer.getRenderer().drawTileLayer(tiles.renderer, layer, tiles.area); + const baked = target(layer); + layer.renderMode = "prerender"; + layer.onActivateEvent(); + layer.draw(baked.renderer, baked.area); + return { tiles: pixels(tiles.renderer), baked: pixels(baked.renderer) }; + }; + + // how many pixels differ, and how many the tiles cover + const differences = ({ tiles, baked }) => { + let differ = 0; + let covered = 0; + for (let i = 0; i < tiles.length; i += 4) { + if (tiles[i + 3] > 0) { + covered++; + } + if ( + tiles[i] !== baked[i] || + tiles[i + 1] !== baked[i + 1] || + tiles[i + 2] !== baked[i + 2] || + tiles[i + 3] !== baked[i + 3] + ) { + differ++; + } + } + return { differ, covered }; + }; + + describe.each(ORIENTATIONS)("%s", (_name, map, ...tilesets) => { + it("bakes every tile the layer draws", () => { + const layer = layerOf(map, ...tilesets); + const { differ, covered } = differences(both(layer)); + expect(covered).toBeGreaterThan(COLS * ROWS * 50); + expect(differ).toBe(0); + }); + + it("shows a tile set at runtime", () => { + const layer = layerOf(map, ...tilesets); + both(layer); + layer.setTile(new Tile(0, 0, 2, layer.tileset), COLS - 1, ROWS - 1); + layer.setTile(new Tile(0, 0, 1, layer.tileset), 1, 0); + expect(differences(both(layer)).differ).toBe(0); + }); + + it("clears a tile at runtime, and only that tile", () => { + const layer = layerOf(map, ...tilesets); + both(layer); + layer.clearTile(2, 1); + layer.clearTile(COLS - 1, 0); + const { differ } = differences(both(layer)); + expect(differ).toBe(0); + }); + }); + + describe("a tile changed at runtime", () => { + const fits = [ + { orientation: "orthogonal", tilewidth: 32, tileheight: 32 }, + { tilewidth: 32, tileheight: 32 }, + ]; + + it("is redrawn in its cell alone where no other tile overlaps it", () => { + const layer = layerOf(...fits); + both(layer); + const bake = vi.spyOn(layer, "_bakePreRender"); + layer.setTile(new Tile(0, 0, 2, layer.tileset), 0, 0); + layer.clearTile(1, 0); + const tiles = target(layer); + layer.getRenderer().drawTileLayer(tiles.renderer, layer, tiles.area); + const baked = target(layer); + layer.draw(baked.renderer, baked.area); + expect(bake).not.toHaveBeenCalled(); + expect( + differences({ + tiles: pixels(tiles.renderer), + baked: pixels(baked.renderer), + }).differ, + ).toBe(0); + }); + + it("bakes the layer again, once, before it next draws where tiles overlap", () => { + const layer = layerOf( + ...layout("isometric, tiles taller than their cell"), + ); + both(layer); + const bake = vi.spyOn(layer, "_bakePreRender"); + layer.setTile(new Tile(0, 0, 2, layer.tileset), 0, 0); + layer.clearTile(1, 1); + layer.clearTile(2, 2); + expect(bake).not.toHaveBeenCalled(); + const { renderer, area } = target(layer); + layer.draw(renderer, area); + layer.draw(renderer, area); + expect(bake).toHaveBeenCalledTimes(1); + }); + + it("leaves a layer drawn tile by tile alone", () => { + const layer = layerOf(...fits); + const bake = vi.spyOn(layer, "_bakePreRender"); + layer.setTile(new Tile(0, 0, 2, layer.tileset), 0, 0); + layer.clearTile(1, 0); + expect(layer.canvasRenderer).toBeUndefined(); + expect(bake).not.toHaveBeenCalled(); + }); + }); + + it("draws only the part of the bake in view, from inside the bake", () => { + // an oblique layer skewed left: its bake starts left of 0. A view + // starting before the bake reads from its first pixel, and is drawn + // where that pixel belongs; read from before it, a GPU renderer + // would stretch the bake's edge over the gap + const layer = layerOf(...ORIENTATIONS.at(-1).slice(1)); + layer.renderMode = "prerender"; + layer.onActivateEvent(); + const canvas = layer.canvasRenderer.getCanvas(); + const originX = layer._preRenderX; + const originY = layer._preRenderY; + expect(originX).toBeLessThan(0); + const calls = []; + const spy = { + drawImage(...args) { + calls.push(args.slice(1)); + }, + }; + const draw = CanvasRenderer.prototype.drawTileLayer; + draw.call(spy, layer, new Rect(originX - 30, originY - 20, 100, 60)); + expect(calls).toEqual([[0, 0, 70, 40, originX, originY, 70, 40]]); + // running past the bake's far corner + calls.length = 0; + const right = originX + canvas.width; + const bottom = originY + canvas.height; + draw.call(spy, layer, new Rect(right - 10, bottom - 5, 50, 50)); + expect(calls).toEqual([ + [ + canvas.width - 10, + canvas.height - 5, + 10, + 5, + right - 10, + bottom - 5, + 10, + 5, + ], + ]); + // wholly outside it + calls.length = 0; + draw.call(spy, layer, new Rect(right + 1, originY, 20, 20)); + expect(calls).toEqual([]); + }); + + describe("drawn tile by tile from inside the map", () => { + it.each([ + ["orthogonal, tiles smaller than their cell"], + ["orthogonal, small tiles scaled to their cell"], + ])("%s: draws every tile in view", (name) => { + // a view starting 20 pixels in, inside the first column + const layer = layerOf(...layout(name)); + const full = target(layer); + layer.getRenderer().drawTileLayer(full.renderer, layer, full.area); + const part = target(layer); + const view = new Rect(20, full.area.top, 400, full.area.height); + layer.getRenderer().drawTileLayer(part.renderer, layer, view); + const a = pixels(full.renderer); + const b = pixels(part.renderer); + const width = full.renderer.getCanvas().width; + let differ = 0; + for (let i = 0; i < a.length; i += 4) { + // the pixels from x = 20 on, in the view + if ((i / 4) % width >= 20 - full.area.left && a[i + 3] !== b[i + 3]) { + differ++; + } + } + expect(differ).toBe(0); + }); + }); + + describe("a tile changed after the bake was drawn", () => { + // the renderer drawing the bake keeps a copy of it: a texture on the + // GPU, a tinted image on Canvas. The change must reach that copy + const fits = [ + { orientation: "orthogonal", tilewidth: 32, tileheight: 32 }, + { tilewidth: 32, tileheight: 32 }, + ]; + const change = (layer) => { + layer.setTile(new Tile(0, 0, 2, layer.tileset), 0, 0); + layer.clearTile(2, 1); + layer.clearTile(COLS - 1, ROWS - 1); + }; + + describe("on WebGL", () => { + const SIZE = 320; + let gl; + + beforeAll(async () => { + try { + gl = await getWebGLRenderer(SIZE, SIZE); + } catch { + // no WebGL here: the tests below mark themselves skipped + } + if (gl) { + gl.projectionMatrix.ortho(0, SIZE, SIZE, 0, -1000, 1000); + gl.currentBatcher.setProjection(gl.projectionMatrix); + } + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + // the layer drawn on WebGL with its top left corner at (40, 40) + const draw = (drawLayer) => { + gl.clearColor("#000000ff"); + gl.save(); + gl.translate(40, 40); + drawLayer(); + gl.flush(); + gl.restore(); + const out = new Uint8Array(SIZE * SIZE * 4); + gl.gl.readPixels( + 0, + 0, + SIZE, + SIZE, + gl.gl.RGBA, + gl.gl.UNSIGNED_BYTE, + out, + ); + return out; + }; + + it.each([ + ["redrawn in its cell", fits], + ["baked again", layout("isometric, tiles taller than their cell")], + ])("shows when the tile is %s", (_how, args, ctx) => { + if (gl === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + const layer = layerOf(...args); + const area = new Rect(-40, -40, SIZE, SIZE); + layer.renderMode = "prerender"; + layer.onActivateEvent(); + const before = draw(() => { + layer.draw(gl, area); + }); + change(layer); + const after = draw(() => { + layer.draw(gl, area); + }); + const expected = draw(() => { + layer.getRenderer().drawTileLayer(gl, layer, area); + }); + let changed = 0; + let differ = 0; + for (let i = 0; i < after.length; i++) { + changed += after[i] !== before[i] ? 1 : 0; + differ += after[i] !== expected[i] ? 1 : 0; + } + expect(changed).toBeGreaterThan(0); + expect(differ).toBe(0); + }); + }); + + it("shows on a tinted layer on Canvas", () => { + const layer = layerOf(...fits); + const tinted = target(layer); + tinted.renderer.setTint(new Color(255, 255, 0)); + layer.renderMode = "prerender"; + layer.onActivateEvent(); + layer.draw(tinted.renderer, tinted.area); + change(layer); + tinted.renderer.clearRect(-1e4, -1e4, 2e4, 2e4); + layer.draw(tinted.renderer, tinted.area); + // the same tiles, drawn one by one with the same tint + const tiles = target(layer); + tiles.renderer.setTint(new Color(255, 255, 0)); + layer.getRenderer().drawTileLayer(tiles.renderer, layer, tiles.area); + expect( + differences({ + tiles: pixels(tiles.renderer), + baked: pixels(tinted.renderer), + }).differ, + ).toBe(0); + }); + }); + + it("bakes without announcing a resize to the game", () => { + // the game's own renderer takes the size a resize announces + const resized = vi.fn(); + event.on(event.CANVAS_ONRESIZE, resized); + try { + const layer = layerOf( + ...layout("hexagonal, staggered along y, with a tile offset"), + ); + both(layer); + const canvas = layer.canvasRenderer.getCanvas(); + layer.clearTile(0, 0); + const { renderer, area } = target(layer); + layer.draw(renderer, area); + // baked again into the same canvas + expect(layer.canvasRenderer.getCanvas()).toBe(canvas); + expect(resized).not.toHaveBeenCalled(); + } finally { + event.off(event.CANVAS_ONRESIZE, resized); + } + }); +}); diff --git a/packages/melonjs/tests/tmxobject.spec.js b/packages/melonjs/tests/tmxobject.spec.js index f39215db95..5a7a834f08 100644 --- a/packages/melonjs/tests/tmxobject.spec.js +++ b/packages/melonjs/tests/tmxobject.spec.js @@ -440,6 +440,95 @@ describe("TMXObject", () => { ]); }); } + + describe("ellipses", () => { + // where Tiled puts the point at angle t of the ellipse inside + // the object's box, the box turned by `rotation` about the + // object's position + const onEllipse = (tw, th, w, h, t, rotation = 0) => { + const x = w / 2 + (w / 2) * Math.cos(t); + const y = h / 2 + (h / 2) * Math.sin(t); + const c = Math.cos(rotation); + const s = Math.sin(rotation); + return project(tw, th, x * c - y * s, x * s + y * c); + }; + const ellipse = (map, w, h, rotation = 0) => { + return new TMXObject( + map, + { + name: "", + x: 0, + y: 0, + width: w, + height: h, + rotation, + ellipse: true, + }, + 0, + ); + }; + + for (const [tw, th] of [ + [64, 32], + [128, 32], + [186, 108], + ]) { + it.each([[0], [30]])( + `projects a circle turned by %s degrees with ${tw}x${th} tiles, as an ellipse`, + (degrees) => { + const rotation = (degrees * Math.PI) / 180; + const shape = ellipse(isoMap(tw, th), 100, 100, degrees) + .shapes[0]; + expect(shape.type).toEqual("Ellipse"); + // every point of the projected circle lies on it + for (let k = 0; k < 16; k++) { + const [x, y] = onEllipse( + tw, + th, + 100, + 100, + (k * Math.PI) / 8, + rotation, + ); + const u = (x - shape.pos.x) / shape.radiusV.x; + const v = (y - shape.pos.y) / shape.radiusV.y; + expect(u * u + v * v).toBeCloseTo(1, 6); + } + }, + ); + + it(`projects any other ellipse with ${tw}x${th} tiles, as a polygon on it`, () => { + const shape = ellipse(isoMap(tw, th), 216, 108).shapes[0]; + expect(shape.type).toEqual("Polygon"); + const expected = []; + for (let k = 0; k < 16; k++) { + expected.push(onEllipse(tw, th, 216, 108, (k * Math.PI) / 8)); + } + expectPoints(shape, expected); + }); + } + + it("turns any other ellipse with the object before projecting it", () => { + const rotation = Math.PI / 6; + const shape = ellipse(isoMap(186, 108), 216, 108, 30).shapes[0]; + const expected = []; + for (let k = 0; k < 16; k++) { + expected.push( + onEllipse(186, 108, 216, 108, (k * Math.PI) / 8, rotation), + ); + } + expectPoints(shape, expected); + }); + + it("leaves an ellipse on an orthogonal map as it was", () => { + const shape = ellipse(mockMap(), 216, 108).shapes[0]; + expect(shape.type).toEqual("Ellipse"); + expect(shape.pos.x).toEqual(108); + expect(shape.pos.y).toEqual(54); + expect(shape.radiusV.x).toEqual(108); + expect(shape.radiusV.y).toEqual(54); + }); + }); }); }); diff --git a/packages/melonjs/tests/tmxrenderer.spec.js b/packages/melonjs/tests/tmxrenderer.spec.js index af42434560..0c24c4cf2e 100644 --- a/packages/melonjs/tests/tmxrenderer.spec.js +++ b/packages/melonjs/tests/tmxrenderer.spec.js @@ -8,6 +8,7 @@ import TMXStaggeredRenderer from "../src/level/tiled/renderer/TMXStaggeredRender import Tile from "../src/level/tiled/TMXTile.js"; import TMXTileset from "../src/level/tiled/TMXTileset.js"; import { imgList } from "../src/loader/cache.js"; +import CanvasRenderer from "../src/video/canvas/canvas_renderer.js"; // helper to create a fake image in the loader cache function fakeImage(name, w = 64, h = 64) { @@ -331,7 +332,6 @@ describe("TMX Renderers", () => { restore() {}, translate() {}, transform() {}, - uvOffset: 0, }; } @@ -368,6 +368,24 @@ describe("TMX Renderers", () => { expect(spy.calls[0].dh).toEqual(48); }); + it("draws a tile at its own size on a Canvas renderer without smoothing", () => { + // it used to be drawn a pixel larger there, to hide the seams + // smoothing drew once a resize had turned it back on + const renderer = new CanvasRenderer({ + width: 8, + height: 8, + antiAlias: false, + }); + const calls = []; + renderer.drawImage = (img, sx, sy, sw, sh, dx, dy, dw, dh) => { + calls.push({ dw, dh }); + }; + const ts = makeTileset({}, 48, 48); + const ortho = new TMXOrthogonalRenderer(mockMap()); + ortho.drawTile(renderer, 0, 0, new Tile(0, 0, 1, ts)); + expect(calls).toEqual([{ dw: 48, dh: 48 }]); + }); + it("should scale tile to grid size with tilerendersize='grid' stretch", () => { const ts = makeTileset({ tilerendersize: "grid" }, 32, 32); const tile = new Tile(0, 0, 1, ts); @@ -675,7 +693,6 @@ describe("TMX Renderers", () => { restore() {}, translate() {}, transform() {}, - uvOffset: 0, }; const renderer = new TMXObliqueRenderer(obliqueMap(16, 0)); // draw tile at grid (0, 5) — dx should include skewX * 5 = 80 diff --git a/packages/melonjs/tests/vertexBuffer.spec.js b/packages/melonjs/tests/vertexBuffer.spec.js index e182e9c352..1c0bd7d91e 100644 --- a/packages/melonjs/tests/vertexBuffer.spec.js +++ b/packages/melonjs/tests/vertexBuffer.spec.js @@ -21,40 +21,14 @@ describe("VertexArrayBuffer", () => { expect(buf.bufferU32[5]).toBe(0xffffffff); // tint }); - it("should write vertex data with textureId (7 floats)", () => { - // vertexSize=7 matches the multi-texture quad format - const buf = new VertexArrayBuffer(7, 4); - - buf.push(10, 20, 5, 0.0, 1.0, 0xffffffff, 3); - - expect(buf.vertexCount).toBe(1); - expect(buf.bufferF32[0]).toBe(10); // x - expect(buf.bufferF32[1]).toBe(20); // y - expect(buf.bufferF32[2]).toBe(5); // z - expect(buf.bufferF32[3]).toBe(0.0); // u - expect(buf.bufferF32[4]).toBe(1.0); // v - expect(buf.bufferU32[5]).toBe(0xffffffff); // tint - expect(buf.bufferF32[6]).toBe(3); // textureId - }); - - it("should write default textureId 0 when not provided (vertexSize 7)", () => { - const buf = new VertexArrayBuffer(7, 4); - - buf.push(10, 20, 0, 0.0, 1.0, 0xffffffff); - - expect(buf.vertexCount).toBe(1); - expect(buf.bufferF32[6]).toBe(0); // default 0 - }); - - it("should not write textureId when vertexSize is 6", () => { + it("writes only the 6 floats of the primitive layout", () => { + // sprites have their own layout and writer (pushSprite); push() + // must not reach past its own vertex into the next one const buf = new VertexArrayBuffer(6, 4); - - // write a sentinel at offset 6 buf.bufferF32[6] = 99; buf.push(10, 20, 0, 0.0, 1.0, 0xffffffff); - expect(buf.vertexCount).toBe(1); - expect(buf.bufferF32[6]).toBe(99); // untouched + expect(buf.bufferF32[6]).toBe(99); }); it("should write multiple vertices sequentially", () => { @@ -72,72 +46,45 @@ describe("VertexArrayBuffer", () => { expect(buf.bufferF32[10]).toBe(1.0); // v / ny expect(buf.bufferU32[11]).toBe(0x00ff0000); // tint }); + }); - it("should write multiple vertices with textureId sequentially", () => { - const buf = new VertexArrayBuffer(7, 4); - - buf.push(1, 2, 0, 0.0, 0.0, 0xff000000, 0); - buf.push(3, 4, 0, 1.0, 1.0, 0x00ff0000, 5); - - expect(buf.vertexCount).toBe(2); - expect(buf.bufferF32[6]).toBe(0); // textureId vertex 0 - expect(buf.bufferF32[13]).toBe(5); // textureId vertex 1 - }); - - it("should write vertex data with textureId + normalTextureId (8 floats)", () => { - // vertexSize=8 matches the lit-pipeline quad format: - // x, y, z, u, v, tint, textureId, normalTextureId - const buf = new VertexArrayBuffer(8, 4); - - buf.push(10, 20, 5, 0.0, 1.0, 0xffffffff, 3, 2); - + describe("pushSprite()", () => { + it("writes the sprite layout: position, UV, tint, slot, then the frame", () => { + const buf = new VertexArrayBuffer(9, 4); + buf.pushSprite(10, 20, 5, 0.0, 1.0, 0xffffffff, 3, 0.5, 0.25); expect(buf.vertexCount).toBe(1); - expect(buf.bufferF32[0]).toBe(10); - expect(buf.bufferF32[1]).toBe(20); - expect(buf.bufferF32[2]).toBe(5); // z - expect(buf.bufferF32[3]).toBe(0.0); - expect(buf.bufferF32[4]).toBe(1.0); + expect(Array.from(buf.bufferF32.slice(0, 5))).toEqual([10, 20, 5, 0, 1]); expect(buf.bufferU32[5]).toBe(0xffffffff); expect(buf.bufferF32[6]).toBe(3); // textureId - expect(buf.bufferF32[7]).toBe(2); // normalTextureId + expect([buf.bufferF32[7], buf.bufferF32[8]]).toEqual([0.5, 0.25]); }); - it("should default normalTextureId to -1 when not provided (vertexSize 8)", () => { - // Regression test: with vertexSize=8, omitting `normalTextureId` - // must NOT leave stale data at offset 7. The shader's lit path - // activates on `vNormalTextureId >= 0`; reading garbage there - // causes unlit sprites to render through the lit path with - // random normal-map / light state — visible as broken - // hemispheric shading on every WebGL example after the - // normal-map vertex format was introduced. - const buf = new VertexArrayBuffer(8, 4); - // poison the slot to prove push() actively writes -1 - buf.bufferF32[7] = 99; - - buf.push(10, 20, 0, 0.0, 1.0, 0xffffffff, 3); - - expect(buf.bufferF32[7]).toBe(-1); + it("writes consecutive vertices one stride apart", () => { + const buf = new VertexArrayBuffer(9, 4); + buf.pushSprite(1, 2, 0, 0, 0, 0xff000000, 0, 1, 1); + buf.pushSprite(3, 4, 0, 1, 1, 0x00ff0000, 5, 0, 0); + expect(buf.vertexCount).toBe(2); + expect(buf.bufferF32[6]).toBe(0); + expect(buf.bufferF32[9]).toBe(3); + expect(buf.bufferF32[15]).toBe(5); }); - it("should accept negative normalTextureId (sentinel for unlit)", () => { - const buf = new VertexArrayBuffer(8, 4); - buf.push(0, 0, 0, 0, 0, 0, 0, -1); - expect(buf.bufferF32[7]).toBe(-1); + it("writes a lit layout's normal map slot and shininess after the frame", () => { + // the WebGL lit layout: 11 floats. -1 is the "no normal map" + // sentinel the lit batcher writes for an unlit sprite + const buf = new VertexArrayBuffer(11, 4); + buf.pushSprite(0, 0, 0, 0, 0, 0, 2, 0.5, 0.5, -1, 0); + expect(buf.bufferF32[9]).toBe(-1); + expect(buf.bufferF32[10]).toBe(0); }); - it("should not touch offset 7 when vertexSize is 7 (backward compat)", () => { - // older quad batchers using vertexSize=7 mustn't get any - // writes past offset 6 - const buf = new VertexArrayBuffer(7, 4); - // pre-fill the next vertex's first slot with a sentinel - buf.bufferF32[7] = 77; - - // pass a normalTextureId — should be silently dropped because - // vertexSize is 7 (the unlit layout has no `aNormalTextureId`) - buf.push(10, 20, 0, 0, 0, 0xff, 1, 9); - // offset 7 is the next vertex's first float — verify push() - // didn't bleed into it - expect(buf.bufferF32[7]).toBe(77); + it("does not reach past its own vertex", () => { + for (const size of [9, 10, 11]) { + const buf = new VertexArrayBuffer(size, 4); + buf.bufferF32[size] = 77; + buf.pushSprite(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1); + expect(buf.bufferF32[size]).toBe(77); + } }); }); @@ -161,7 +108,7 @@ describe("VertexArrayBuffer", () => { it("should include fallback to uSampler0", () => { const src = buildMultiTextureFragment(2); expect(src).toContain("} else {"); - expect(src).toContain("color = texture2D(uSampler0, vRegion);"); + expect(src).toContain("color = frameSample(uSampler0, vRegion, gx, gy);"); }); it("should generate a single sampler for maxTextures=1", () => { @@ -173,7 +120,7 @@ describe("VertexArrayBuffer", () => { it("should include vColor multiplication", () => { const src = buildMultiTextureFragment(2); - expect(src).toContain("gl_FragColor = color * vColor;"); + expect(src).toContain("fragColor = color * vColor;"); }); }); @@ -413,12 +360,12 @@ describe("VertexArrayBuffer", () => { }, ); - it("textureId slot in vertexSize=7 writes through the float view", () => { + it("the sprite layout's textureId slot writes through the float view", () => { // Color sits at slot 5 (Uint32); textureId at slot 6 // stays Float32 — its value range (small integers // 0..N) never forms a NaN pattern. - const buf = new VertexArrayBuffer(7, 4); - buf.push(0, 0, 0, 0, 0, 0xffd7dee8, 3); + const buf = new VertexArrayBuffer(9, 4); + buf.pushSprite(0, 0, 0, 0, 0, 0xffd7dee8, 3, 0, 0); expect(buf.bufferF32[6]).toBe(3); expect(buf.bufferU32[5]).toBe(0xffd7dee8); expect(buf.bufferU8[5 * 4 + 3]).toBe(0xff); diff --git a/packages/melonjs/tests/webgl_vao_call_counts.spec.js b/packages/melonjs/tests/webgl_vao_call_counts.spec.js index 9893034473..29215d15b1 100644 --- a/packages/melonjs/tests/webgl_vao_call_counts.spec.js +++ b/packages/melonjs/tests/webgl_vao_call_counts.spec.js @@ -69,14 +69,15 @@ describe("WebGL VAO call counts (#1509 acceptance)", () => { } }; - it("rebuilding all vertex states costs exactly 20 pointer + 20 enable calls", (ctx) => { + it("rebuilding all vertex states costs exactly 22 pointer + 22 enable calls", (ctx) => { + // one per attribute: quad 5, litQuad 7, primitive 3, mesh 3, litMesh 4 requireWebGL(ctx); resetCounts(); for (const batcher of renderer.batchers.values()) { batcher.createVertexState(); } - expect(counts.vertexAttribPointer).toBe(20); - expect(counts.enableVertexAttribArray).toBe(20); + expect(counts.vertexAttribPointer).toBe(22); + expect(counts.enableVertexAttribArray).toBe(22); expect(counts.disableVertexAttribArray).toBe(0); }); diff --git a/packages/melonjs/tests/webgl_vao_state.spec.js b/packages/melonjs/tests/webgl_vao_state.spec.js index b7a26e83a5..992daadf67 100644 --- a/packages/melonjs/tests/webgl_vao_state.spec.js +++ b/packages/melonjs/tests/webgl_vao_state.spec.js @@ -20,8 +20,8 @@ describe("WebGL vertex state (VAO) isolation", () => { // (batcher name → expected stride in bytes) const STRIDES = { - quad: 28, - litQuad: 36, + quad: 36, + litQuad: 44, primitive: 24, mesh: 36, litMesh: 48, diff --git a/packages/melonjs/tests/webgpu_lit_quads.spec.js b/packages/melonjs/tests/webgpu_lit_quads.spec.js index 3422abbbcf..af4735e555 100644 --- a/packages/melonjs/tests/webgpu_lit_quads.spec.js +++ b/packages/melonjs/tests/webgpu_lit_quads.spec.js @@ -67,8 +67,9 @@ describe("WebGPU 2D lighting", () => { it("declares its OWN vertex layout, wider than the shared quad one", () => { // widening the shared layout would charge every unlit sprite - // in the engine for a term it never evaluates - expect(lit.stride).toBe(32); + // in the engine for a term it never evaluates: the quad + // layout's 36 bytes, plus the exponent + expect(lit.stride).toBe(40); expect( lit.attributes.map((a) => { return a.name; @@ -80,7 +81,7 @@ describe("WebGPU 2D lighting", () => { const v = quadWith(48); const size = lit.vertexSize; for (let i = 0; i < 4; i++) { - expect(v[i * size + 7]).toBe(48); + expect(v[i * size + 9]).toBe(48); } }); @@ -90,16 +91,17 @@ describe("WebGPU 2D lighting", () => { const v = quadWith(0); const size = lit.vertexSize; for (let i = 0; i < 4; i++) { - expect(v[i * size + 7]).toBe(0); + expect(v[i * size + 9]).toBe(0); } }); it("does not leak one sprite's exponent onto the next", () => { + // the exponent is float 9, after the frame (floats 7 and 8) const shiny = quadWith(64); - expect(shiny[7]).toBe(64); + expect(shiny[9]).toBe(64); lit.flush(); const matte = quadWith(0); - expect(matte[7]).toBe(0); + expect(matte[9]).toBe(0); }); it("the shader gates the term on the exponent, and ADDS it", () => { diff --git a/packages/melonjs/tests/webgpu_post_effect.spec.js b/packages/melonjs/tests/webgpu_post_effect.spec.js index bacabc76ad..dbe2bbd919 100644 --- a/packages/melonjs/tests/webgpu_post_effect.spec.js +++ b/packages/melonjs/tests/webgpu_post_effect.spec.js @@ -284,15 +284,20 @@ fn apply(color : vec4f, uv : vec2f) -> vec4f { renderer.pipelineCache.emptyBindGroup, ); - // the last write is the quad: 4×28 bytes, v=0 at the top (no flip) + // the last write is the quad: 4×36 bytes, v=0 at the top (no flip) const quadWrite = renderer.calls.writes.at(-1); - expect(quadWrite.size).toBe(112); + expect(quadWrite.size).toBe(144); // vertex 0 uv = (0,0); vertex 2 (x, y+h) uv = (0,1) expect([quadWrite.floats[3], quadWrite.floats[4]]).toEqual([0, 0]); - expect([quadWrite.floats[17], quadWrite.floats[18]]).toEqual([0, 1]); + expect([quadWrite.floats[21], quadWrite.floats[22]]).toEqual([0, 1]); + // a blit draws the whole target: its frame is unbounded + for (let v = 0; v < 4; v++) { + expect(quadWrite.floats[v * 9 + 7]).toBe(-1e6); + expect(quadWrite.floats[v * 9 + 8]).toBe(-1e6); + } }); - it("fast path: adopting customShader drains the pending batch, then draws per quad", async () => { + it("fast path: effect sprites share a draw until a uniform changes", async () => { const { default: WebGPUQuadBatcherClass } = await import( "../src/video/webgpu/batchers/quad_batcher.js" ); @@ -306,44 +311,84 @@ fn apply(color : vec4f, uv : vec2f) -> vec4f { return { width: 64, height: 32 }; }, }; + const snapshots = () => { + return renderer.calls.writes + .filter((write) => { + return write.size === 16; + }) + .map((write) => { + return write.floats[0]; + }); + }; // two plain quads batch together... batcher.addQuad(atlas, 0, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); batcher.addQuad(atlas, 8, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); expect(renderer.calls.drawIndexed).toEqual([]); - // ...the effect sprite adopts: pending batch drains under the DEFAULT - // pipeline, then the effect quad draws alone under the effect family + // ...the effect sprite adopts: the pending batch drains under the + // DEFAULT pipeline, and the effect quad waits for company renderer.customShader = effect; effect.setUniform("uStrength", 0.5); batcher.addQuad(atlas, 16, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); - expect(renderer.calls.drawIndexed).toEqual([12, 6]); + expect(renderer.calls.drawIndexed).toEqual([12]); expect(renderer.calls.pipelineKeys[0]).toContain("quad|"); + + // the same uniform value again changes nothing: it joins the draw + effect.setUniform("uStrength", 0.5); + batcher.addQuad(atlas, 24, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + expect(renderer.calls.drawIndexed).toEqual([12]); + + // a new value draws the two queued under the old one first, with it + effect.setUniform("uStrength", 0.9); + expect(renderer.calls.drawIndexed).toEqual([12, 12]); // blending KEPT — live compositing is the fast path's semantic - expect(renderer.calls.pipelineKeys[1]).toBe( + expect(renderer.calls.pipelineKeys.at(-1)).toBe( "effect:0|triangle-list|normal|true|none", ); + batcher.addQuad(atlas, 32, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + expect(renderer.calls.drawIndexed).toEqual([12, 12]); - // a second effect sprite is its own draw with its own snapshot - effect.setUniform("uStrength", 0.9); - batcher.addQuad(atlas, 24, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); - expect(renderer.calls.drawIndexed).toEqual([12, 6, 6]); - const snapshots = renderer.calls.writes.filter((write) => { - return write.size === 16; - }); - expect( - snapshots.map((write) => { - return write.floats[0]; - }), - ).toEqual([0.5, expect.closeTo(0.9)]); - - // clearing customShader returns to plain batching + // clearing customShader draws the queued effect quad, with ITS value, + // and returns to plain batching renderer.customShader = undefined; - batcher.addQuad(atlas, 32, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + batcher.addQuad(atlas, 40, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + expect(renderer.calls.drawIndexed).toEqual([12, 12, 6]); + expect(snapshots()).toEqual([0.5, expect.closeTo(0.9)]); batcher.flush(); expect(renderer.calls.pipelineKeys.at(-1)).toContain("quad|"); }); + it("fast path: a change to an effect no longer drawn draws nothing", async () => { + // the hook belongs to the batcher's current effect only + const { default: WebGPUQuadBatcherClass } = await import( + "../src/video/webgpu/batchers/quad_batcher.js" + ); + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUQuadBatcherClass(renderer); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const first = new ShaderEffect({ shaderLanguage: "wgsl" }, { wgsl: BODY }); + const second = new ShaderEffect({ shaderLanguage: "wgsl" }, { wgsl: BODY }); + warn.mockRestore(); + const atlas = { + getTexture() { + return { width: 64, height: 32 }; + }, + }; + renderer.customShader = first; + batcher.addQuad(atlas, 0, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + renderer.customShader = second; + batcher.addQuad(atlas, 8, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + const draws = renderer.calls.drawIndexed.length; + // `first` is not the one queued: changing it draws nothing + first.setUniform("uStrength", 0.3); + expect(renderer.calls.drawIndexed.length).toBe(draws); + expect(first.wgslRealization.onUniformChange).toBe(null); + // `second` is: changing it draws its quad + second.setUniform("uStrength", 0.7); + expect(renderer.calls.drawIndexed.length).toBe(draws + 1); + }); + it("fast path: screen_texture effects capture the backdrop before each sprite", async () => { const { default: WebGPUQuadBatcherClass } = await import( "../src/video/webgpu/batchers/quad_batcher.js" diff --git a/packages/melonjs/tests/webgpu_quad_batcher.spec.js b/packages/melonjs/tests/webgpu_quad_batcher.spec.js index 6bd08ef4bb..628ecc9ef0 100644 --- a/packages/melonjs/tests/webgpu_quad_batcher.spec.js +++ b/packages/melonjs/tests/webgpu_quad_batcher.spec.js @@ -27,20 +27,20 @@ describe("WebGPUQuadBatcher", () => { }).toThrow(/attributes definition missing/); }); - it("addQuad writes the frozen 28-byte layout: corners, UVs, packed tint, z", () => { + it("addQuad writes the frozen 36-byte layout: corners, UVs, packed tint, z, frame", () => { renderer.currentDepth = 2; batcher.addQuad(atlasA, 10, 20, 30, 40, 0.1, 0.2, 0.3, 0.4, 0xffaabbcc); batcher.flush(); expect(renderer.calls.drawIndexed).toEqual([6]); const { size, floats, view } = renderer.calls.writes[0]; - expect(size).toBe(4 * 28); + expect(size).toBe(4 * 36); - // corner order: (x,y) (x+w,y) (x,y+h) (x+w,y+h); stride = 7 floats + // corner order: (x,y) (x+w,y) (x,y+h) (x+w,y+h); stride = 9 floats expect([floats[0], floats[1]]).toEqual([10, 20]); - expect([floats[7], floats[8]]).toEqual([40, 20]); - expect([floats[14], floats[15]]).toEqual([10, 60]); - expect([floats[21], floats[22]]).toEqual([40, 60]); + expect([floats[9], floats[10]]).toEqual([40, 20]); + expect([floats[18], floats[19]]).toEqual([10, 60]); + expect([floats[27], floats[28]]).toEqual([40, 60]); // per-sprite depth stamped on z (identity transform passes it through) expect(floats[2]).toBe(2); // per-corner UV mapping @@ -48,7 +48,7 @@ describe("WebGPUQuadBatcher", () => { expect.closeTo(0.1), expect.closeTo(0.2), ]); - expect([floats[10], floats[11]]).toEqual([ + expect([floats[12], floats[13]]).toEqual([ expect.closeTo(0.3), expect.closeTo(0.2), ]); @@ -56,6 +56,15 @@ describe("WebGPUQuadBatcher", () => { expect(view.getUint32(20, true)).toBe(0xffaabbcc); // aTextureId is 0 under single-texture batching expect(floats[6]).toBe(0); + // and each corner carries the opposite corner's UV: the frame + expect([floats[7], floats[8]]).toEqual([ + expect.closeTo(0.3), + expect.closeTo(0.4), + ]); + expect([floats[34], floats[35]]).toEqual([ + expect.closeTo(0.1), + expect.closeTo(0.2), + ]); }); it("the current transform is applied to all four corners", () => { @@ -65,7 +74,7 @@ describe("WebGPUQuadBatcher", () => { const floats = renderer.calls.writes[0].floats; expect([floats[0], floats[1]]).toEqual([110, 1020]); - expect([floats[21], floats[22]]).toEqual([140, 1060]); + expect([floats[27], floats[28]]).toEqual([140, 1060]); }); it("a texture change claims a new slot — quads batch across textures in ONE segment", () => { @@ -80,12 +89,12 @@ describe("WebGPUQuadBatcher", () => { expect(renderer.calls.drawIndexed).toEqual([18]); expect(renderer.calls.materialBinds).toHaveLength(1); // aTextureId per vertex: quads 1-2 slot 0, quad 3 slot 1 (float - // at offset 6 of each 7-float vertex) + // at offset 6 of each 9-float vertex) const vertexWrite = renderer.calls.writes.find((w) => { return w.buffer === renderer.vertexArena.page; }); const slotOf = (vertex) => { - return vertexWrite.floats[vertex * 7 + 6]; + return vertexWrite.floats[vertex * 9 + 6]; }; expect(slotOf(0)).toBe(0); expect(slotOf(4)).toBe(0); diff --git a/packages/melonjs/tests/webgpu_tmxlayer.spec.js b/packages/melonjs/tests/webgpu_tmxlayer.spec.js index 43d0f24d90..f5ebdb8117 100644 --- a/packages/melonjs/tests/webgpu_tmxlayer.spec.js +++ b/packages/melonjs/tests/webgpu_tmxlayer.spec.js @@ -68,6 +68,31 @@ describe("WebGPU orthogonal TMX layer renderer (mock)", () => { expect(again.key).toBe(tmx.key); }); + it("draws its quad in the sprite vertex layout, with no frame to keep to", () => { + // four vertices of the quad batcher's own stride (it grew from 28 to + // 36 bytes when sprite vertices took a frame): a quad written or bound + // at another size is not drawn on a real device + const layer = makeLayer(); + tmx.draw(layer, RECT); + const batcher = renderer.currentBatcher; + const size = 4 * batcher.stride; + expect(batcher.stride).toBe(36); + expect(renderer.calls.vertexBufferBinds.at(-1).size).toBe(size); + const quad = renderer.calls.writes.find((write) => { + return write.size === size; + }); + expect(quad).toBeDefined(); + const floats = batcher.stride / 4; + const uvs = []; + for (let v = 0; v < 4; v++) { + const at = v * floats; + uvs.push(`${quad.floats[at + 3]},${quad.floats[at + 4]}`); + // the whole index texture is read: no frame + expect(quad.floats[at + 7]).toBe(-1e6); + } + expect(uvs.sort()).toEqual(["0,0", "0,1", "1,0", "1,1"]); + }); + it("uploads the GID index once per data version", () => { const layer = makeLayer(); tmx.draw(layer, RECT);