• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

excaliburjs / Excalibur / 19716547001

26 Nov 2025 08:27PM UTC coverage: 88.647% (+0.08%) from 88.57%
19716547001

push

github

web-flow
feat!: debug draw improvements (#3585)

<!--
Hi, and thanks for contributing to Excalibur!
Before you go any further, please read our contributing guide: https://github.com/excaliburjs/Excalibur/blob/main/.github/CONTRIBUTING.md
especially the "Submitting Changes" section:
https://github.com/excaliburjs/Excalibur/blob/main/.github/CONTRIBUTING.md#submitting-changes
---
A quick summary checklist is included below for convenience:
-->

===:clipboard: PR Checklist :clipboard:===

- [ ] :pushpin: issue exists in github for these changes
- [x] :microscope: existing tests still pass
- [x] :see_no_evil: code conforms to the [style guide](https://github.com/excaliburjs/Excalibur/blob/main/STYLEGUIDE.md)
- [x] :triangular_ruler: new tests written and passing / old tests updated with new scenario(s)
- [x] :page_facing_up: changelog entry added (or not needed)

==================

Discussed in our core contributor group, we are wanting to switch the "design language" of debug draw to help users more clearly identify problems.

<img width="1107" height="851" alt="image" src="https://github.com/user-attachments/assets/76c5fd59-2ede-4847-8138-ecd7cca5e210" />


1. "Bounds" type drawings will now be dashed boxes
2. Debug Graphics for line, point, point are no longer zoom coupled, so they'll always render at X pixels on screen regardless of zoom to make it easier to zoom in/out and debug things.
3. Breaking change! in bounds debug drawing to allow specifying more props
4. Fixed dupe graphics bounds drawing in DebugDrawSystem
5. Improved performance of debug drawing!


TODOS:
* [x] Debug Specific Circle Renderer
* [x] Uncouple Debug Text from Camera zoom
* [x] Add configuration option for dashed

5288 of 7219 branches covered (73.25%)

280 of 306 new or added lines in 18 files covered. (91.5%)

3 existing lines in 1 file now uncovered.

14656 of 16533 relevant lines covered (88.65%)

24557.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.43
/src/engine/Graphics/GraphicsSystem.ts
1
import type { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext';
2
import type { Scene } from '../Scene';
3
import { GraphicsComponent } from './GraphicsComponent';
4
import { vec, Vector } from '../Math/vector';
5
import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent';
6
import type { Entity } from '../EntityComponentSystem/Entity';
7
import type { Camera } from '../Camera';
8
import type { Query, World } from '../EntityComponentSystem';
9
import { System, SystemPriority, SystemType } from '../EntityComponentSystem';
10
import type { Engine } from '../Engine';
11
import { GraphicsGroup } from './GraphicsGroup';
12
import { ParallaxComponent } from './ParallaxComponent';
13
import { CoordPlane } from '../Math/coord-plane';
14
import { BodyComponent } from '../Collision/BodyComponent';
15
import { FontCache } from './FontCache';
16
import { PostDrawEvent, PostTransformDrawEvent, PreDrawEvent, PreTransformDrawEvent } from '../Events';
17
import { Transform } from '../Math/transform';
18
import { blendTransform } from './TransformInterpolation';
19
import { Graphic } from './Graphic';
20

21
export class GraphicsSystem extends System {
248✔
22
  static priority = SystemPriority.Average;
23

24
  public readonly systemType = SystemType.Draw;
1,306✔
25
  private _token = 0;
1,306✔
26
  // Set in the initialize
27
  private _graphicsContext!: ExcaliburGraphicsContext;
28
  private _camera!: Camera;
29
  private _engine!: Engine;
30
  private _sortedTransforms: TransformComponent[] = [];
1,306✔
31
  query: Query<typeof TransformComponent | typeof GraphicsComponent>;
32
  public get sortedTransforms() {
33
    return this._sortedTransforms;
1✔
34
  }
35

36
  constructor(public world: World) {
1,306✔
37
    super();
1,306✔
38
    this.query = this.world.query([TransformComponent, GraphicsComponent]);
1,306✔
39
    this.query.entityAdded$.subscribe((e) => {
1,306✔
40
      const tx = e.get(TransformComponent);
1,961✔
41
      this._sortedTransforms.push(tx);
1,961✔
42
      tx.zIndexChanged$.subscribe(this._zIndexUpdate);
1,961✔
43
      this._zHasChanged = true;
1,961✔
44
    });
45
    this.query.entityRemoved$.subscribe((e) => {
1,306✔
46
      const tx = e.get(TransformComponent);
34✔
47
      tx.zIndexChanged$.unsubscribe(this._zIndexUpdate);
34✔
48
      const index = this._sortedTransforms.indexOf(tx);
34✔
49
      if (index > -1) {
34!
50
        this._sortedTransforms.splice(index, 1);
34✔
51
      }
52
    });
53
  }
54

55
  public initialize(world: World, scene: Scene): void {
56
    this._camera = scene.camera;
683✔
57
    this._engine = scene.engine;
683✔
58
  }
59

60
  private _zHasChanged = false;
1,306✔
61
  private _zIndexUpdate = () => {
1,306✔
62
    this._zHasChanged = true;
898✔
63
  };
64

65
  public preupdate(): void {
66
    // Graphics context could be switched to fallback in a new frame
67
    this._graphicsContext = this._engine.graphicsContext;
946✔
68
    if (this._zHasChanged) {
946✔
69
      this._sortedTransforms.sort((a, b) => {
293✔
70
        return a.globalZ - b.globalZ;
5,307✔
71
      });
72
      this._zHasChanged = false;
293✔
73
    }
74
  }
75

76
  public update(elapsed: number): void {
77
    this._token++;
945✔
78
    let graphics: GraphicsComponent;
79
    FontCache.checkAndClearCache();
945✔
80

81
    // This is a performance enhancement, most things are in world space
82
    // so if we can only do this once saves a ton of transform updates
83
    this._graphicsContext.save();
945✔
84
    if (this._camera) {
945!
85
      this._camera.draw(this._graphicsContext);
945✔
86
    }
87
    for (let transformIndex = 0; transformIndex < this._sortedTransforms.length; transformIndex++) {
945✔
88
      const transform = this._sortedTransforms[transformIndex];
2,380✔
89
      const entity = transform.owner as Entity;
2,380✔
90

91
      // If the entity is offscreen skip
92
      if (entity.hasTag('ex.offscreen')) {
2,380✔
93
        continue;
77✔
94
      }
95

96
      graphics = entity.get(GraphicsComponent);
2,303✔
97
      // Exit if graphics set to not visible
98
      if (!graphics.isVisible) {
2,303✔
99
        continue;
5✔
100
      }
101

102
      // Optionally run the onPreTransformDraw graphics lifecycle draw
103
      if (graphics.onPreTransformDraw) {
2,298!
104
        graphics.onPreTransformDraw(this._graphicsContext, elapsed);
×
105
      }
106
      entity.events.emit('pretransformdraw', new PreTransformDrawEvent(this._graphicsContext, elapsed, entity));
2,298✔
107

108
      // This optionally sets our camera based on the entity coord plan (world vs. screen)
109
      if (transform.coordPlane === CoordPlane.Screen) {
2,298✔
110
        this._graphicsContext.restore();
21✔
111
      }
112

113
      this._graphicsContext.save();
2,298✔
114
      if (transform.coordPlane === CoordPlane.Screen) {
2,298✔
115
        this._graphicsContext.translate(this._engine.screen.contentArea.left, this._engine.screen.contentArea.top);
21✔
116
      }
117

118
      // Tick any graphics state (but only once) for animations and graphics groups
119
      graphics.update(elapsed, this._token);
2,298✔
120

121
      // Apply parallax
122
      const parallax = entity.get(ParallaxComponent);
2,298✔
123
      if (parallax) {
2,298✔
124
        // We use the Tiled formula
125
        // https://doc.mapeditor.org/en/latest/manual/layers/#parallax-scrolling-factor
126
        // cameraPos * (1 - parallaxFactor)
127
        const oneMinusFactor = Vector.One.sub(parallax.parallaxFactor);
5✔
128
        const parallaxOffset = this._camera.drawPos.scale(oneMinusFactor);
5✔
129
        this._graphicsContext.translate(parallaxOffset.x, parallaxOffset.y);
5✔
130
      }
131

132
      // Position the entity + estimate lag
133
      this._applyTransform(entity);
2,298✔
134

135
      // If there is a material enable it on the context
136
      if (graphics.material) {
2,298✔
137
        this._graphicsContext.material = graphics.material;
3✔
138
      }
139

140
      // Optionally run the onPreDraw graphics lifecycle draw
141
      if (graphics.onPreDraw) {
2,298!
142
        graphics.onPreDraw(this._graphicsContext, elapsed);
×
143
      }
144
      entity.events.emit('predraw', new PreDrawEvent(this._graphicsContext, elapsed, entity));
2,298✔
145

146
      // this._graphicsContext.opacity *= graphics.opacity;
147
      this._applyOpacity(entity);
2,298✔
148

149
      // Draw the graphics component
150
      this._drawGraphicsComponent(graphics, transform);
2,298✔
151

152
      // Optionally run the onPostDraw graphics lifecycle draw
153
      if (graphics.onPostDraw) {
2,298✔
154
        graphics.onPostDraw(this._graphicsContext, elapsed);
978✔
155
      }
156
      entity.events.emit('postdraw', new PostDrawEvent(this._graphicsContext, elapsed, entity));
2,298✔
157

158
      this._graphicsContext.restore();
2,298✔
159

160
      // Reset the transform back to the original world space
161
      if (transform.coordPlane === CoordPlane.Screen) {
2,298✔
162
        this._graphicsContext.save();
21✔
163
        if (this._camera) {
21!
164
          this._camera.draw(this._graphicsContext);
21✔
165
        }
166
      }
167

168
      // Optionally run the onPreTransformDraw graphics lifecycle draw
169
      if (graphics.onPostTransformDraw) {
2,298!
170
        graphics.onPostTransformDraw(this._graphicsContext, elapsed);
×
171
      }
172
      entity.events.emit('posttransformdraw', new PostTransformDrawEvent(this._graphicsContext, elapsed, entity));
2,298✔
173
    }
174
    this._graphicsContext.restore();
945✔
175
  }
176

177
  private _drawGraphicsComponent(graphicsComponent: GraphicsComponent, transformComponent: TransformComponent) {
178
    if (graphicsComponent.isVisible) {
2,298!
179
      const flipHorizontal = graphicsComponent.flipHorizontal;
2,298✔
180
      const flipVertical = graphicsComponent.flipVertical;
2,298✔
181

182
      const graphic = graphicsComponent.current;
2,298✔
183
      const options = graphicsComponent.currentOptions ?? {};
2,298!
184

185
      if (graphic) {
2,298✔
186
        let anchor = graphicsComponent.anchor;
1,065✔
187
        let offset = graphicsComponent.offset;
1,065✔
188
        let scaleX = 1;
1,065✔
189
        let scaleY = 1;
1,065✔
190
        // handle specific overrides
191
        if (options?.anchor) {
1,065!
192
          anchor = options.anchor;
×
193
        }
194
        if (options?.offset) {
1,065!
195
          offset = options.offset;
×
196
        }
197
        const globalScale = transformComponent.globalScale;
1,065✔
198
        scaleX *= graphic.scale.x * globalScale.x;
1,065✔
199
        scaleY *= graphic.scale.y * globalScale.y;
1,065✔
200

201
        // See https://github.com/excaliburjs/Excalibur/pull/619 for discussion on this formula
202
        const offsetX = -graphic.width * anchor.x + offset.x * scaleX;
1,065✔
203
        const offsetY = -graphic.height * anchor.y + offset.y * scaleY;
1,065✔
204

205
        const oldFlipHorizontal = graphic.flipHorizontal;
1,065✔
206
        const oldFlipVertical = graphic.flipVertical;
1,065✔
207
        if (flipHorizontal || flipVertical) {
1,065✔
208
          // flip any currently flipped graphics
209
          graphic.flipHorizontal = flipHorizontal ? !oldFlipHorizontal : oldFlipHorizontal;
4✔
210
          graphic.flipVertical = flipVertical ? !oldFlipVertical : oldFlipVertical;
4✔
211
        }
212

213
        graphic?.draw(this._graphicsContext, offsetX, offsetY);
1,065!
214

215
        if (flipHorizontal || flipVertical) {
1,065✔
216
          graphic.flipHorizontal = oldFlipHorizontal;
4✔
217
          graphic.flipVertical = oldFlipVertical;
4✔
218
        }
219

220
        // This debug code is in-situ to avoid recalculating the positioning of graphics
221
        if (this._engine?.isDebug && this._engine.debug.graphics.showBounds) {
1,065!
NEW
222
          this._graphicsContext.save();
×
223
          const offset = vec(offsetX, offsetY);
×
224
          if (graphic instanceof GraphicsGroup) {
×
225
            for (const member of graphic.members) {
×
226
              let g: Graphic;
227
              let pos: Vector = Vector.Zero;
×
228
              if (member instanceof Graphic) {
×
229
                g = member;
×
230
              } else {
231
                g = member.graphic;
×
232
                pos = member.offset;
×
233
              }
234

235
              if (graphic.useAnchor) {
×
NEW
236
                g?.localBounds
×
237
                  .translate(offset.add(pos))
238
                  .debug(this._graphicsContext, { color: this._engine.debug.graphics.boundsColor, dashed: true });
239
              } else {
NEW
240
                g?.localBounds
×
241
                  .translate(pos)
242
                  .debug(this._graphicsContext, { color: this._engine.debug.graphics.boundsColor, dashed: true });
243
              }
244
            }
245
          } else {
246
            /* istanbul ignore next */
NEW
247
            graphic?.localBounds
×
248
              .translate(offset)
249
              .debug(this._graphicsContext, { color: this._engine.debug.graphics.boundsColor, dashed: true });
250
          }
NEW
251
          this._graphicsContext.restore();
×
252
        }
253
      }
254
    }
255
  }
256

257
  private _targetInterpolationTransform = new Transform();
1,306✔
258
  /**
259
   * This applies the current entity transform to the graphics context
260
   * @param entity
261
   */
262
  private _applyTransform(entity: Entity): void {
263
    const ancestors = entity.getAncestors();
2,298✔
264
    for (let i = 0; i < ancestors.length; i++) {
2,298✔
265
      const ancestor = ancestors[i];
3,204✔
266
      const transform = ancestor?.get(TransformComponent);
3,204!
267
      const optionalBody = ancestor?.get(BodyComponent);
3,204!
268
      if (transform) {
3,204✔
269
        let tx = transform.get();
3,203✔
270
        if (optionalBody) {
3,203✔
271
          if (this._engine.fixedUpdateTimestep && optionalBody.__oldTransformCaptured && optionalBody.enableFixedUpdateInterpolate) {
2,224✔
272
            // Interpolate graphics if needed
273
            const blend = this._engine.currentFrameLagMs / this._engine.fixedUpdateTimestep;
392✔
274
            tx = blendTransform(optionalBody.oldTransform, transform.get(), blend, this._targetInterpolationTransform);
392✔
275
          }
276
        }
277
        this._graphicsContext.z = transform.globalZ;
3,203✔
278
        this._graphicsContext.translate(tx.pos.x, tx.pos.y);
3,203✔
279
        this._graphicsContext.scale(tx.scale.x, tx.scale.y);
3,203✔
280
        this._graphicsContext.rotate(tx.rotation);
3,203✔
281
      }
282
    }
283
  }
284

285
  private _applyOpacity(entity: Entity): void {
286
    const ancestors = entity.getAncestors();
2,298✔
287
    for (let i = 0; i < ancestors.length; i++) {
2,298✔
288
      const ancestor = ancestors[i];
3,204✔
289
      const maybeGraphics = ancestor?.get(GraphicsComponent);
3,204!
290
      this._graphicsContext.opacity *= maybeGraphics?.opacity ?? 1;
3,204✔
291
    }
292
  }
293
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc