Skip to content

Commit d82b867

Browse files
obiotclaude
andauthored
Display: integer scaling for pixel art, and a skill for the settings that decide how a game looks (#1751)
* Display: a whole number of device pixels per source pixel, and a skill for the seven settings that decide how a game looks `scaleRounding: "integer"` constrains the automatic scale factor so every source pixel covers the same whole number of device pixels, letterboxing whatever is left over. The default stays `"fractional"`, so nothing existing changes. A 480 wide design in a 1280 wide container is scaled by 2.667, so some source pixels cover three device pixels and some cover two. Pixel art shows that as uneven glyph stems and shimmering edges, and browsers distribute the unevenness differently under `image-rendering: pixelated`, so the same scene is crisp in one and soft in another. Measured on one of this repository's own examples: a 480x270 frame at 1280x720 CSS on a 2x display is a 5.33x upscale at a non-integer ratio, with both engines reporting identical geometry and identical computed CSS. Two decisions worth stating: - It applies to `scaleMethod: "fit"` only. Every other method calls `renderer.resize()`, so the design resolution follows the container and there is no fixed factor to round. - The rounding is applied in DEVICE pixels. `scale()` multiplies by `devicePixelRatio` on the way to the canvas, so rounding the CSS factor would let the fraction straight back in: 2 CSS at a ratio of 1.25 is 2.5 device pixels per source pixel. That second point is why the arithmetic is an exported pure function rather than three lines inline. The test environment runs at a ratio of 1, where CSS and device pixels are the same number, so no DOM test can tell the correct implementation from one that floors the CSS factor: that mutation passed the whole integration suite. Calling `integerDeviceScale` directly is what pins it. Five mutations, five caught, with a positive control. Also adds `melonjs-display-and-scaling`, because seven settings now decide how a game reaches the screen and nothing said which owns which job: the design resolution, `scale`/`scaleMethod`/`scaleTarget`, `scaleRounding`, `antiAlias`, `textureFilter` and `subPixel`. It covers the two that surprise people most, that `antiAlias` drives the texture filter and the CSS upscale as well as MSAA, and that `subPixel` snaps positions inside the frame while doing nothing about how the frame reaches the screen, which is the contradiction integer scaling resolves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t * Docs: say what the four scaling settings actually do Comments only. `antiAlias` and `textureFilter` already carry their detail; these four did not, and between them they are where the surprises live. - `scale`: that a fixed number means nothing is measured, and that `scaleMethod`, `scaleTarget` and `scaleRounding` apply to `"auto"` alone - `scaleMethod`: it said "screen scaling modes". The distinction that decides which to pick is whether the DESIGN RESOLUTION moves: `"fit"` keeps it and the game always sees the world it was authored for, while every other mode resizes the renderer, so the game sees more or less of the world on a different window and has to cope with a viewport it did not choose - `scaleTarget`: that the default is the PARENT of the container the canvas was mounted in, one level further out than callers expect, and that a `position: fixed` container contributes no height to that parent. When the measurement lands on `<body>` the window size is used instead and the canvas comes out larger than its box, cropped by exactly the height of whatever sits above it, on every resize. That is a real bug this repository's own examples page had until this release - `subPixel`: it said "avoid sprite flickering when using transforms", which does not say that the snap TRUNCATES, nor that it truncates toward zero, so an offset swinging between -3 and +3 lands on -2 and +2 and sits at each extreme longer than it should. It also now says what it does NOT do: it is about position inside the frame, not about how the frame reaches the screen, which is `scaleRounding`'s half typedoc resolves every added link and emits no warning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t * Docs: an undocumented setting, and a picture of the wrong thing Comments only. Found by auditing every property rather than by reading the ones I happened to be near. `toneMapping` had NO doc at all, sitting as a bare line between `toneMappingExposure` and `toneMappingWhite`, which both have one. It now says what the three curves are and carries the warning the runtime setter already makes: a curve is a look decision rather than a companion to `hdr`, because every curve maps 1 to less than 1, so switching one on goes matte on art authored against a clamp. It also says which of the three is a shader rebuild and which two are uniform writes, since that is what decides whether a settings screen can drive them per frame. `ScaleMethods.Manual` illustrated itself with `scale-fit.png`, Fit's picture, which is the one thing manual scaling is not. There is no manual illustration to use, so it now says there is nothing to show rather than showing something false. `ScaleMethods.Fit` said "letterboxed; content is scaled to design aspect ratio", which does not say the part that decides whether you want it: the design resolution is KEPT, so the game sees exactly the world it was authored for, where every other mode resizes the renderer and hands it a viewport it did not choose. It is also the only mode `scaleRounding` applies to. `transparent` said "whether to allow transparent pixels in the front buffer", which does not say what it costs. It now names what actually changes: clear alpha 1 to 0, WebGPU `"opaque"` to `"premultiplied"`, and the canvas renderer clearing rather than erasing, with opaque being the cheaper surface to composite. Left alone deliberately: `verbose` and `consoleHeader` are one-line booleans that a second line would pad rather than explain, and `compositor` is already marked `@deprecated since 18.1.0`, so growing its documentation would point people at it. typedoc clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t * Examples: Jungle Rabbit gets whole pixels It is a 480x270 frame, so it was the example that showed the problem: at 1280x720 CSS on a 2x display the factor was 2.667, which is 5.333 device pixels per source pixel, so some landed on five and some on six. Chunky pixels of unequal size is not the look it is going for, and browsers distribute the unevenness differently, so the same scene was crisp in one and soft in another. Measured in both engines: identical backing size, identical CSS size, identical computed `image-rendering`, so the scale was the whole difference. With `scaleRounding: "integer"` the frame is presented at 1200x675 instead of 1280x720: a CSS factor of 2.5, which is exactly 5 device pixels per source pixel. It gives up 80px of width to margin, which is the trade the setting exists to offer. It sits next to `antiAlias: false` and `textureFilter: "nearest"` because it is the third part of the same decision, and the one neither of those can make. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t * Docs: what integer scaling actually costs, and Jungle Rabbit opts back out "Letterboxes with whatever is left over" undersells it, and the size of the bill is set by `devicePixelRatio` as much as by the window, which is the part nobody would guess. A higher ratio has more device pixels to round among, so it lands closer to the factor it wanted. For a 480x270 design, which is exactly a quarter of 1920x1080: container ratio fit integer presented area lost 1920x1080 1 or 2 4.000 4 / 8 1920x1080 none 1440x810 1 3.000 3 1440x810 none 1280x720 2 2.667 5 1200x675 12% 1280x720 1 2.667 2 960x540 44% 1366x768 1 2.844 2 960x540 51% So the decision is mostly made when the design resolution is CHOSEN, not when this setting is. One that divides the sizes you expect costs nothing; one that does not can give up half the screen on a 1x display. Which is why Jungle Rabbit no longer sets it. A 1366x768 laptop is not rare and losing half the window to black bars is a worse look, on an example whose job is to look right on whatever a visitor opens it on, than the uneven pixels it was fixing. The setting stays, the example stays fractional, and the table is in both the JSDoc and the skill so the trade is visible before anyone reaches for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t * Docs: why text stays soft when every sprite is crisp The scaling skill could not answer the case it is most likely to be asked about. A game whose art is hard-edged and whose text is not does not have a scaling problem: `Text` draws through `fillText`, which anti-aliases glyph edges into partial alpha before any scaling happens, and no canvas setting turns that off. The upscale then magnifies each grey edge pixel into a grey block. It is also why one browser looks crisp and another soft at identical settings, since engines ship different font rasterizers. Adds that section, the symptom-table row pointing at it, and what it takes to bake an atlas that survives magnification: threshold the coverage rather than the alpha, bake each size at the size it is drawn, carve the outline inward around a counter instead of growing it in, and pad the cell for a face whose glyphs overshoot the baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e616265 commit d82b867

10 files changed

Lines changed: 534 additions & 10 deletions

File tree

‎.claude-plugin/plugin.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "melonjs",
33
"version": "20.9.0",
4-
"description": "Build games with melonJS — 23 guides to the 2D, 2.5D and 3D HTML5 game engine, its conventions and idioms, so generated code runs the first time.",
4+
"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.",
55
"author": {
66
"name": "melonJS",
77
"url": "https://melonjs.org"
@@ -32,6 +32,7 @@
3232
"./packages/melonjs/skills/melonjs-deployment",
3333
"./packages/melonjs/skills/melonjs-loading-assets",
3434
"./packages/melonjs/skills/melonjs-renderer-backends",
35+
"./packages/melonjs/skills/melonjs-display-and-scaling",
3536
"./packages/melonjs/skills/melonjs-performance",
3637
"./packages/melonjs/skills/melonjs-events",
3738
"./packages/melonjs/skills/melonjs-plugins"

‎packages/melonjs/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## [20.9.0] (melonJS 2) - _unreleased_
44

55
### Added
6+
- **`scaleRounding`**: `"integer"` constrains the automatic scale factor to a whole number of device pixels per source pixel, letterboxing whatever is left over, where the default `"fractional"` fills the container exactly. A 480 wide design in a 1280 wide container is scaled by 2.667, so some source pixels cover three device pixels and some cover two, which pixel art shows as uneven stems and shimmering edges and which browsers resolve differently, so the same scene looks crisp in one and soft in another. The rounding is applied in DEVICE pixels, since `devicePixelRatio` would otherwise put the fraction back. It affects `scaleMethod: "fit"` only, because every other method resizes the renderer to its container and so has no fixed factor to round
67
- **Sprite-sheet particles** (`framewidth`, `frameheight`, `frames`, `randomFrame` on `ParticleEmitter`): a particle plays a frame sequence once over its own lifetime, or holds one frame picked at random when it is born. One sheet is one image, so a burst of varied debris still batches into a single draw call ([#1610](https://github.com/melonjs/melonJS/issues/1610), thanks @snowyukitty)
78
- **`slerp(v, alpha)` on all four vector classes**: interpolate along the ARC between two directions at a constant angular rate, where `lerp` slows at the ends and shortens the vector through the middle. 2D takes the shorter way round, and the same and opposite cases are handled rather than returning `NaN`
89
- **`Trail3d`**: a fading, tapering ribbon that recedes properly into a 3D scene, for an exhaust streak, a speed trail or a spell arc. It is a `Mesh`, so unlike the 2D `Trail` it narrows along its own length, takes a post effect and a `tint`, and costs one indexed draw call rather than a filled path per segment. It needs a `Camera3d` ([#1621](https://github.com/melonjs/melonJS/issues/1621))

‎packages/melonjs/skills/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
> Gemini CLI and anything else following the `AGENTS.md` convention will read it
1212
> from your project root. Claude Code users should install the plugin instead —
1313
> `/plugin marketplace add melonjs/melonJS` — which registers the same guidance
14-
> as 24 routed skills.
14+
> as 25 routed skills.
1515
1616
This project uses **melonJS 20.x**, an open-source HTML5 game engine covering
1717
2D, 2.5D and 3D, running on WebGPU, WebGL 2 or Canvas with automatic fallback.
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
---
2+
name: melonjs-display-and-scaling
3+
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."
4+
license: MIT
5+
---
6+
7+
# Display and scaling
8+
9+
Seven settings decide how a game looks on screen, and they are easy to get
10+
individually right and collectively wrong. This is what each one actually
11+
does, and which one to reach for.
12+
13+
## The four jobs
14+
15+
A pixel travels from your source art to a physical pixel through four
16+
separate decisions. Knowing which setting owns which is most of the work.
17+
18+
| job | setting | what it decides |
19+
|---|---|---|
20+
| how big the frame is drawn | `width`/`height` on `Application` | the design resolution everything is authored against |
21+
| how it uses the space | `scale`, `scaleMethod`, `scaleTarget` | fit, fill, stretch, and what it measures to decide |
22+
| may the factor be fractional | `scaleRounding` | whole device pixels per source pixel, or any factor |
23+
| how a texture is sampled | `antiAlias`, `textureFilter` | nearest or linear, and the CSS upscale filter |
24+
25+
`subPixel` is a fifth, orthogonal to all of them: it decides whether a
26+
renderable may sit on a fractional position WITHIN the frame.
27+
28+
## Design resolution
29+
30+
```js
31+
const app = new Application(480, 270, { scale: "auto" });
32+
```
33+
34+
The game is drawn at 480x270 whatever the window is, and the canvas is scaled
35+
to the container. Everything in world coordinates is in those units. This is
36+
the usual arrangement: author once, scale to whatever you are given.
37+
38+
## `scale` and `scaleMethod`
39+
40+
`scale: "auto"` turns on automatic scaling; a number is a fixed multiplier and
41+
`scaleMethod` is then ignored.
42+
43+
`scaleMethod` decides how the available space is used:
44+
45+
- **`"fit"`** keeps the design resolution and the aspect ratio, scaling to the
46+
largest factor that fits. Leftover space becomes margin. This is the one to
47+
use when the game is authored for a fixed frame.
48+
- **`"fill-min"` / `"fill-max"`** keep the aspect ratio but RESIZE the
49+
renderer, so the design resolution changes with the window and the game sees
50+
more or less of the world.
51+
- **`"flex"`, `"flex-width"`, `"flex-height"`** resize the renderer on one or
52+
both axes.
53+
- **`"stretch"`** scales each axis independently, so the aspect ratio is not
54+
preserved and circles become ovals.
55+
- **`"manual"`** does nothing automatic.
56+
57+
The distinction that matters: **`fit` scales a fixed frame; everything else
58+
changes how much of the world is visible.** A game whose layout assumes a
59+
known frame wants `fit`.
60+
61+
## `scaleTarget`: what gets measured
62+
63+
Automatic scaling measures **the parent of the element the canvas is mounted
64+
in**, not the element itself. If that grandparent is not the box you meant,
65+
the canvas is sized wrongly, and the failure is quiet.
66+
67+
The case that bites: a canvas container that is `position: fixed` or
68+
`absolute` contributes no height to its parent, so the parent measures only
69+
whatever else is in it. If that parent is `<body>`, the engine falls back to
70+
the window size and the canvas ends up larger than the box it lives in,
71+
cropped by whatever sits above it.
72+
73+
```js
74+
new Application(1024, 576, {
75+
parent: "screen",
76+
scale: "auto",
77+
// measure THIS, rather than guessing from the DOM shape
78+
scaleTarget: document.getElementById("stage"),
79+
});
80+
```
81+
82+
Either name the element with `scaleTarget`, or give the canvas container a
83+
parent whose box is the area you want filled. If the canvas is cropped or
84+
overflows on resize, this is the first thing to check.
85+
86+
## `scaleRounding`: whole device pixels
87+
88+
```js
89+
new Application(480, 270, {
90+
scale: "auto",
91+
scaleMethod: "fit",
92+
scaleRounding: "integer", // default "fractional"
93+
});
94+
```
95+
96+
`"fractional"` fills the container exactly, at whatever factor that takes. A
97+
480 wide design in a 1280 wide container is scaled by 2.667, so some source
98+
pixels cover three screen pixels and some cover two. Pixel art shows this as
99+
uneven glyph stems and shimmering edges, and browsers resolve the unevenness
100+
differently, so the same scene can look crisp in one and soft in another.
101+
102+
`"integer"` rounds the factor DOWN so every source pixel covers the same whole
103+
number of device pixels, and letterboxes with the space left over. That trade,
104+
crisp pixels for black bars, is the whole decision.
105+
106+
### What it costs
107+
108+
More than "some letterboxing", and the size of it is decided by
109+
`devicePixelRatio` as much as by the window. A higher ratio has more device
110+
pixels to round among, so it lands closer to the factor it wanted. For a
111+
480x270 design, which is exactly a quarter of 1920x1080:
112+
113+
| container | ratio | fit | integer | presented | area lost |
114+
|---|---|---|---|---|---|
115+
| 1920x1080 | 1 or 2 | 4.000 | 4 / 8 | 1920x1080 | none |
116+
| 1440x810 | 1 | 3.000 | 3 | 1440x810 | none |
117+
| 960x540 | 1 | 2.000 | 2 | 960x540 | none |
118+
| 1280x720 | 2 | 2.667 | 5 | 1200x675 | 12% |
119+
| 2560x1440 | 2 | 5.333 | 10 | 2400x1350 | 12% |
120+
| 1280x720 | 1 | 2.667 | 2 | 960x540 | **44%** |
121+
| 1366x768 | 1 | 2.844 | 2 | 960x540 | **51%** |
122+
123+
So the decision is mostly made when the design resolution is chosen, not when
124+
this setting is. One that divides the sizes you expect costs nothing; one that
125+
does not can give up half the screen on a 1x display, and 1366x768 laptops are
126+
not rare. A game that must fill the window on any machine should stay
127+
fractional and accept the uneven pixels.
128+
129+
Two things to know:
130+
131+
- It applies to **`scaleMethod: "fit"` only.** Every other method resizes the
132+
renderer to the container, so the design resolution moves and there is no
133+
fixed factor to round.
134+
- The rounding happens in **device** pixels. `devicePixelRatio` multiplies the
135+
CSS size on the way to real pixels, so rounding the CSS factor would let the
136+
fraction straight back in: a factor of 2 at a ratio of 1.25 is 2.5 device
137+
pixels per source pixel.
138+
139+
## `antiAlias` does three things
140+
141+
This is the setting most likely to surprise, because it is not one switch:
142+
143+
1. the texture filter, but only while `textureFilter` is `"auto"` (the
144+
default), which TRACKS it: linear when on, nearest when off. Setting
145+
`textureFilter` explicitly decouples the two
146+
2. `imageSmoothingEnabled` on the 2D context
147+
3. the canvas CSS `image-rendering`: `pixelated` when off, `auto` when on
148+
149+
So `antiAlias: false` gives hard-edged textures AND a hard-edged upscale of
150+
the whole frame. That is usually what a pixel-art game wants, and usually NOT
151+
what a smooth 3D scene wants, because it also disables MSAA.
152+
153+
`textureFilter: "nearest" | "linear"` separates the texture half, so a scene
154+
can have smooth model edges and nearest-sampled sprites. The presentation half
155+
is not separable: there is no way to get MSAA silhouettes and a hard-edged
156+
upscale at the same time.
157+
158+
## `subPixel` is about position, not scale
159+
160+
`subPixel: false` (the default) truncates a renderable's translation to whole
161+
pixels inside the frame. It is `|0`, so it truncates **toward zero** rather
162+
than rounding, which is worth knowing when something travels a short distance:
163+
an offset that should swing between -3 and +3 lands on -2 and +2, and parks at
164+
the extremes for longer than it should.
165+
166+
It does nothing about how the frame reaches the screen. A game that carefully
167+
snaps positions and is then scaled by 2.667 has thrown that work away at the
168+
last step, which is the contradiction `scaleRounding: "integer"` exists to
169+
resolve.
170+
171+
## Soft text next to crisp sprites
172+
173+
If everything is crisp EXCEPT the text, the scale factor is not the cause: a
174+
fractional factor softens the whole frame equally. The text is different
175+
because of how it is made.
176+
177+
`Text` draws through the browser's `fillText`, which anti-aliases glyph edges
178+
into partial-alpha pixels. Measured on one label at 16px: **171 distinct
179+
partial alpha levels**. Nothing in the canvas API turns that off. `antiAlias`
180+
and the `imageSmoothingEnabled` that `CanvasRenderTarget` sets govern IMAGE
181+
drawing, not glyph rasterisation.
182+
183+
So the label arrives already soft, and the upscale then magnifies each grey
184+
edge pixel into a grey block, sitting beside sprites that were hard-edged to
185+
begin with and stay hard-edged under nearest sampling.
186+
187+
It also explains the same scene looking crisp in one browser and soft in
188+
another at identical size and settings: engines use different font
189+
rasterisers, with different hinting and anti-aliasing, so they bake DIFFERENT
190+
textures from the same font at the same size.
191+
192+
**Use `BitmapText` for a game that upscales.** A pixel-font atlas is
193+
hard-edged source art like every other texture, so it survives magnification
194+
the way the sprites do. Raising the design resolution only shrinks the
195+
magnification of anti-aliasing that is still there, so it softens the symptom
196+
rather than removing it, and `scaleRounding` does nothing for this at all.
197+
198+
Keep `Text` where the frame is not upscaled much, or where the label is meant
199+
to look smooth.
200+
201+
### Baking an atlas from a face you already use
202+
203+
Swapping in a bitmap face means generating one, and three things decide
204+
whether it comes out looking like the original:
205+
206+
- **Threshold the coverage.** Hard edges are the whole point, so every pixel
207+
has to end up fully on or fully off. Hardening only the ALPHA is not enough:
208+
an outlined glyph keeps a soft stroke-to-fill blend INSIDE the silhouette,
209+
which still reads as blur once magnified. Pick the colours from a mask.
210+
- **Bake each size at that size, and draw it at a ratio of 1.** `size` on
211+
`BitmapText` is a ratio of the authored size, and a bitmap face has the sizes
212+
it was baked at. Doubling a smaller atlas halves the detail; a ratio below 1
213+
drops whole stems; a fractional ratio resamples hard pixels unevenly. One
214+
atlas per step of the type scale.
215+
- **Grow the outline outward, but carve it INWARD around a counter.** This is
216+
the one that costs the most time. A stroke drawn by the canvas is centred on
217+
the contour, so around an enclosed hole it eats into the letter and the hole
218+
keeps its size. Dilating a mask outward instead grows black INTO the hole
219+
from every side, which closes anything under about 3px: the letter loses its
220+
counter and reads as a blob. Take the rim from the fill side and the hole
221+
survives at every size. Neither leaving holes unrimmed nor closing the small
222+
ones is a substitute — both were tried and both look wrong.
223+
224+
Two traps in the generator itself:
225+
226+
- A face whose glyphs overshoot the `textBaseline` origin is clipped by a tight
227+
cell, and the symptom is an outline that goes missing along one edge of some
228+
glyphs. Pad the cell by a fraction of the size, and assert that no glyph's
229+
ink reaches its border.
230+
- The threshold decides the TOPOLOGY, not just the weight. A notch or a narrow
231+
gap in a letterform closes over at a full threshold and becomes an isolated
232+
hole, which then bakes as a black blob sitting on the glyph. Thinner, the
233+
same feature stays joined to the background and reads as the face intends.
234+
So a small size may want a thinner fill than a large one, and the way to see
235+
it is to magnify one glyph, not a whole line.
236+
237+
## A pixel-art setup
238+
239+
```js
240+
const app = new Application(480, 270, {
241+
scale: "auto",
242+
scaleMethod: "fit",
243+
scaleRounding: "integer", // whole device pixels, letterbox the rest
244+
antiAlias: false, // nearest textures, hard-edged upscale
245+
// subPixel: false is already the default
246+
});
247+
```
248+
249+
## A smooth 3D setup
250+
251+
```js
252+
const app = new Application(1024, 576, {
253+
scale: "auto",
254+
scaleMethod: "fit",
255+
antiAlias: true, // MSAA and a filtered upscale
256+
textureFilter: "nearest", // but keep blocky textures if the art is blocky
257+
});
258+
```
259+
260+
## Symptoms
261+
262+
| symptom | cause |
263+
|---|---|
264+
| pixel art is blurry or shimmers | the frame is upscaled by a fractional factor; set `scaleRounding: "integer"` |
265+
| crisp in one browser, soft in another | the same fractional factor, which engines distribute differently under `pixelated` |
266+
| the canvas is cropped or overflows its box | automatic scaling measured the wrong element; name it with `scaleTarget` |
267+
| the canvas grows on every resize | the measured ancestor is auto-sized around the canvas itself |
268+
| things shimmer as they move | fractional positions; check `subPixel`, and that the scale factor is whole |
269+
| 3D model edges are jagged | `antiAlias: false` also disables MSAA; turn it on and use `textureFilter` for textures |
270+
| textures are smoothed when they should be blocky | `antiAlias: true` makes the default texture filter linear; set `textureFilter: "nearest"` |
271+
| `scaleRounding: "integer"` changes nothing | it applies to `scaleMethod: "fit"` only; the others resize the renderer |
272+
| text is soft but sprites are crisp | the glyphs were anti-aliased when `Text` baked them; use `BitmapText` (see below) |
273+
| the game shows more world on a bigger window | that is `fill-*` or `flex-*` doing their job; use `"fit"` to keep a fixed frame |

‎packages/melonjs/skills/melonjs/SKILL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ dependencies.
4949
| [melonjs-deployment](../melonjs-deployment/SKILL.md) | Scaffolding a project, building, browser support and transpiling, native and mini-game platforms. |
5050
| [melonjs-loading-assets](../melonjs-loading-assets/SKILL.md) | Resource descriptors, the asset type list, base URLs, cross-origin, progress events, unloading. |
5151
| [melonjs-renderer-backends](../melonjs-renderer-backends/SKILL.md) | A feature works on one machine but not another; choosing or pinning a backend; degrading gracefully. |
52+
| [melonjs-display-and-scaling](../melonjs-display-and-scaling/SKILL.md) | Blurry, soft or cropped output; the design resolution, how the canvas fills its page, and pixel-perfect integer scaling. |
5253
| [melonjs-events](../melonjs-events/SKILL.md) | The event bus, per-frame and lifecycle events, and subscribing without leaking. |
5354
| [melonjs-performance](../melonjs-performance/SKILL.md) | Dropped frames, draw-call counts, pooling, batching, culling, baking, profiling. |
5455
| [melonjs-plugins](../melonjs-plugins/SKILL.md) | Registering the debug, Spine or Tiled-inflate plugins, physics adapters, or writing your own plugin. |

‎packages/melonjs/src/application/defaultApplicationSettings.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const defaultApplicationSettings = {
2424
gpuTilemap: true,
2525
failIfMajorPerformanceCaveat: true,
2626
highPrecisionShader: true,
27+
scaleRounding: "fractional",
2728
subPixel: false,
2829
verbose: false,
2930
backgroundColor: "#000000",

‎packages/melonjs/src/application/resize.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,34 @@ function scale(game: Application, x: number, y: number): void {
3333
game.repaint();
3434
}
3535

36+
/**
37+
* The largest scale at or below `scale` that covers each source pixel with a
38+
* WHOLE number of device pixels.
39+
*
40+
* Rounded down, and rounded in DEVICE pixels rather than CSS ones. That
41+
* distinction is the whole of it: the canvas is given a CSS size and the
42+
* browser multiplies by `devicePixelRatio` on the way to real pixels, so
43+
* flooring the CSS factor lets the fraction come straight back. A factor of
44+
* 2 at a ratio of 1.25 is 2.5 device pixels per source pixel, which is the
45+
* unevenness the setting exists to remove.
46+
*
47+
* Worked through at a ratio of 2, fitting a 480 wide design into 1280: the
48+
* raw factor is 2.667 CSS, which is 5.333 device pixels. Flooring here gives
49+
* 5 device pixels, so a CSS factor of 2.5. Flooring the CSS factor instead
50+
* would give 2, which is 4 device pixels: also whole, and a whole device
51+
* pixel smaller than the container could have afforded.
52+
*
53+
* Never returns less than one device pixel per source pixel. A container
54+
* smaller than the design resolution has no whole factor to give, and a
55+
* scale of zero does not draw at all.
56+
* @param scale - the unconstrained factor, in CSS pixels
57+
* @param ratio - `devicePixelRatio`
58+
* @returns the constrained factor, still in CSS pixels
59+
*/
60+
export function integerDeviceScale(scale: number, ratio: number): number {
61+
return Math.max(1, Math.floor(scale * ratio)) / ratio;
62+
}
63+
3664
/**
3765
* callback for window resize event
3866
* @param game - the game application instance triggering the resize
@@ -138,6 +166,9 @@ export function onresize(game: Application): void {
138166
} else {
139167
scaleX = scaleY = _max_height / settings.height;
140168
}
169+
if (settings.scaleRounding === "integer") {
170+
scaleX = scaleY = integerDeviceScale(scaleX, device.devicePixelRatio);
171+
}
141172
}
142173
}
143174
// adjust scaling ratio

0 commit comments

Comments
 (0)