Graphics topic
Graphics Module
All rendering in Bullseye2D
is handled through the Graphics
object, accessible via app.gfx
.
Coordinate System
By default, Bullseye2D uses a 2D Cartesian coordinate system where:
- The origin
(0,0)
is at the top-left corner of the canvas. - The X-axis increases to the right.
- The Y-axis increases downwards.
This can be customized using the projection matrix (see section on "The 2D Projection").
Colors
Colors are represented by the Color
type. Each component (Red, Green, Blue, Alpha) ranges from 0.0
to 1.0
.
You can set the current drawing color using:
gfx.setColor(double r, double g, double b, double a)
: Sets color with individual components.gfx.setColor(1.0, 0.5, 0.0, 1.0); // Orange
gfx.setColorFrom(Color color)
: Sets color from aColor
object.var myColor = Color(0.0, 1.0, 0.0, 1.0); // Green gfx.setColorFrom(myColor);
For operations that support per-vertex coloring (like gradients on rectangles or polygons), you can use a ColorList
.
Render a rectangle with an gradient
@override
onRender() {
ColorList gradient = [
Color(0.4, 0.7, 0.9, 1.0),
Color(0.8, 0.6, 0.8, 1.0),
Color(0.3, 0.5, 0.8, 1.0),
Color(0.7, 0.5, 0.7, 1.0)
];
gfx.drawRect(0, 0, w, h, colors: gradient)
Blend Modes
You can control how colors are blended when drawn on top of existing pixels using gfx.setBlendMode(BlendMode mode)
. Available modes:
Blend Mode | Description |
---|---|
BlendMode.opaque |
(Default) Source pixels overwrite destination pixels. |
BlendMode.alpha |
Standard alpha blending (typically for pre-multiplied alpha textures). |
BlendMode.additive |
Adds the source color to the destination color (brightening effect). |
BlendMode.multiply |
Multiplies the source color with the destination color, darkening it and respecting source alpha. |
BlendMode.multiply2 |
A stronger darkening effect than BlendMode.multiply . |
BlendMode.screen |
Inverts both colors, multiplies them, and then inverts the result; a brightening effect, often seen as the opposite of multiply. |
gfx.setBlendMode(BlendMode.additive);
gfx.drawImage(myGlowEffectImage, ...);
gfx.setBlendMode(BlendMode.alpha); // Switch back for normal drawing
Clearing the Screen
Before drawing each frame, you typically want to clear the screen.
gfx.clear()
: Clears with the color currently set bygfx.setColor()
.gfx.clear(double r, double g, double b, double a)
: Clears with the specified RGBA color.
// In onRender():
// Clear screen to dark blue
gfx.clear(0.0, 0.0, 0.2, 1.0);
// ... rest of your drawing code ...
Drawing primitives
The gfx
object provides methods to draw various primitives:
Member/Method | Description |
---|---|
gfx.drawPoint(double x, double y) |
Draws a single point at the specified coordinates. |
gfx.drawLine(double x1, y1, x2, y2, {ColorList? colors}) |
Draws a line between two points, optionally with per-vertex colors. |
gfx.drawLines(List<double> vertices, {ColorList? colors}) |
Draws a series of connected lines (line strip). |
gfx.drawRect(double x, y, width, height, {Texture? t, ...}) |
Draws a filled rectangle, optionally textured and with per-vertex colors. |
gfx.drawOval(double x, y, rX, rY, {int segments, ...}) |
Draws a filled oval or ellipse. |
gfx.drawCircle(double x, y, radius, {int segments, ...}) |
Draws a filled circle. |
gfx.drawPoly(List<double> vertices, {List<double>? uvs, ...}) |
Draws a filled polygon from a list of vertices. |
gfx.drawTriangle(double x1,y1, x2,y2, x3,y3, u1,v1, ...) |
Draws a textured triangle with specified vertex and UV coordinates. |
Example: Drawing some primitives
import 'package:bullseye2d/bullseye2d.dart';
class RenderDemo extends App {
@override
onCreate() async {
}
@override
onRender() {
gfx.clear(0, 0, 0, 1);
// draw a line
gfx.drawLine(10, 10, 500, 10);
// draw a rectangle
gfx.drawLines([10, 20, 500, 20, 500, 100, 10, 100, 10, 20]);
// draw a filled rectangle
gfx.drawRect(10, 110, 50, 50);
// draw an ellipse
gfx.drawOval(255, 50, 40, 20);
}
}
main() {
RenderDemo();
}
Rendering Text
Bullseye2D uses Bitmap Fonts for rendering text. A BitmapFont
is generated from a TrueType/OpenType font file by rendering characters into a texture atlas.
Loading a Font
Use app.resources.loadFont()
:
// In onCreate():
late BitmapFont myFont;
myFont = resources.loadFont(
"assets/fonts/my_font.ttf",
32.0, // Font size in pixels
antiAlias: true, // Optional: Smooth rendering (default true)
containedAsciiCharacters: BitmapFont.extendedAscii // Optional: Character set (default extended ASCII)
);
Character Sets for BitmapFont
When loading a BitmapFont
, you can specify which characters to include in the texture atlas using the containedAsciiCharacters
parameter.
Member | Description |
---|---|
BitmapFont.defaultAscii |
Printable ASCII characters (codes 32-126). |
BitmapFont.extendedAscii |
(Default) Includes defaultAscii plus Latin-1 supplement characters (codes 160-255). |
You can also provide your own custom string of characters. Only the characters specified will be available for rendering.
Drawing Text
If you have loaded a BitmapFont
you can use gfx.drawText()
to render text to the screen
// In onRender():
gfx.setColor(1.0, 1.0, 1.0, 1.0); // Set text color (white)
gfx.drawText(myFont, "Hello, World!",
x: 100, y: 150,
alignX: 0.0, // 0.0:left, 0.5:center, 1.0:right (default 0.0)
alignY: 0.0, // 0.0:top, 0.5:middle, 1.0:bottom (default 0.0)
scaleX: 1.0, // Optional horizontal scale
scaleY: 1.0, // Optional vertical scale
);
gfx.drawText
supports newline characters (\n
) for multi-line text.
Measuring Text To get the dimensions of a string before drawing:
Point textSize = gfx.measureText(myFont, "Some text");
log("Text width: ${textSize.x}, height: ${textSize.y}");
Adjusting Spacing:
font.leadingMod
: Multiplier for vertical line spacing (default1.0
).font.tracking
: Multiplier for horizontal character spacing (default1.0
).
myFont.leadingMod = 1.2; // Increase line height by 20%
myFont.tracking = 0.9; // Decrease character spacing by 10%
Images
You can load a image or a spritsheet (an single image that will be sliced up in multiple frames) with resources.loadImage
:
Example of a rotating image
import 'package:bullseye2d/bullseye2d.dart';
class SpritesApp extends App {
int counter = 0;
late Images sprites;
@override
onCreate() async {
sprites = resources.loadImage("assets/gfx/spritesheet.png", frameWidth: 16, frameHeight: 16);
}
@override
onUpdate() {
counter += 1;
}
@override
onRender() {
gfx.clear(0, 0, 0, 1);
int frame = 0;
double rotation = (counter / 2.0) % 360;
const double zoomFactor = 2.0;
// render a rotating sprite
gfx.drawImage(sprites, frame, width / 2, height / 2, rotation, zoomFactor, zoomFactor);
}
}
main() {
SpritesApp();
}
Animating Images
To animate a spritesheet, simply change the frame
index passed to gfx.drawImage()
over time.
Example of a animated image
import 'package:bullseye2d/bullseye2d.dart';
class SpritesApp extends App {
double frame = 0.0;
late Images sprites;
@override
onCreate() async {
sprites = resources.loadImage("assets/gfx/spritesheet.png", frameWidth: 16, frameHeight: 16);
}
@override
onUpdate() {
frame += 0.1;
}
@override
onRender() {
gfx.clear(0, 0, 0, 1);
gfx.drawImage(sprites, frame.floor() % sprites.length, width / 2, height / 2);
}
}
main() {
SpritesApp();
}
Transformations
The graphics system uses a transformation matrix to position, rotate, and scale objects. The matrix stack allows you to save and restore transformation states, which is essential for hierarchical transformations (e.g., a moon rotating around a planet, which itself orbits a sun).
Member/Method | Description |
---|---|
gfx.pushMatrix() |
Saves the current transformation matrix onto a stack. |
gfx.popMatrix() |
Restores the previously saved matrix from the stack, making it the current one. |
gfx.resetMatrix() |
Resets the current transformation matrix to the identity (no transformation). |
gfx.translate(double tx, double ty) |
Moves the origin. |
gfx.rotate(double degrees) |
Rotates around the current origin. |
gfx.scale(double sx, double sy) |
Scales from the current origin. |
gfx.transform(ix, iy, jx, jy, tx, ty) |
Applies a full affine transformation. |
Example
import 'package:bullseye2d/bullseye2d.dart';
class TranslationDemo extends App {
double sunRotation = 0.0;
double planetOrbitAngle = 0.0;
double moonOrbitAngle = 0.0;
@override
onUpdate() {
sunRotation += 0.5;
planetOrbitAngle += 1.0;
moonOrbitAngle += 2.0;
}
@override
onRender() {
gfx.clear(0, 0, 0, 1);
gfx.setColor(1, 1, 1, 1);
// --- Sun ---
gfx.pushMatrix();
gfx.translate(width / 2, height / 2); // Move to center of screen
gfx.rotate(sunRotation);
gfx.drawCircle(0, 0, 100, colors: [Color(1, 1, 0, 1), Color(1, 0.5, 0.5, 1)]); // Draw Sun with radius 100
gfx.pushMatrix();
gfx.rotate(planetOrbitAngle); // Planet's orbital rotation
gfx.translate(250, 0); // Move out to planet's orbital distance
gfx.drawCircle(0, 0, 50, colors: [Color(0, 0, 1, 1), Color(0, 0, 0.5, 1)]); // Draw Planet
// --- Moon (orbits Planet) ---
gfx.rotate(moonOrbitAngle);
gfx.translate(80, 0);
gfx.drawRect(-16, -16, 32, 32, colors: [Color(0.7, 0.7, 0.7, 1)]); // Draw Moon
gfx.popMatrix(); // we pop matrix, so we're back on the on the translations we set up for sun
gfx.rotate(planetOrbitAngle);
gfx.translate(-250, 0);
gfx.drawCircle(0, 0, 50, colors: [Color(1, 0, 1, 1), Color(1, 0, 0.5, 1)]);
gfx.popMatrix();
}
}
main() {
TranslationDemo();
}
Why use a Matrix Stack?
Imagine you want to draw a car with wheels that rotate.
- You translate and rotate the car body to its position.
- To draw the first wheel:
pushMatrix()
: Save the car body's transformation.translate(wheel1_offsetX, wheel1_offsetY)
: Move to where wheel 1 attaches to the body.rotate(wheel1_rotation)
: Rotate the wheel itself.- Draw the wheel (centered at the new 0,0).
popMatrix()
: Restore the transformation to be just the car body's again.
- To draw the second wheel, you do a similar push/translate/rotate/draw/pop sequence, starting from the car body's transformation.
Without the stack, you'd have to manually calculate absolute world coordinates for every part of every object, which becomes very complex with nested rotations and translations. The stack keeps track of relative transformations.
The 2D Projection
The projection matrix transforms your 2D world coordinates into the normalized device coordinates. Bullseye2D
provides gfx.set2DProjection()
to easily set up an orthographic projection.
gfx.set2DProjection({double x = 0.0, double y = 0.0, double? width, double? height})
x, y
: The top-left corner of your visible world.width, height
: The width and height of your visible world. If not provided, they default to the canvas's client width/height.
This means if you call gfx.set2DProjection(width: 800, height: 600)
, your game world will effectively be 800 units wide and 600 units high, regardless of the actual canvas pixel size. The engine handles the scaling.
onResize(int newWidth, int newHeight)
The App.onResize()
method is called automatically whenever the browser window (and thus the canvas) is resized.
Its default implementation is:
// Default App.onResize()
onResize(int width, int height) {
gfx.setViewport(0, 0, width, height);
gfx.set2DProjection(width: width.toDouble(), height: height.toDouble());
}
This makes your game world pixel-perfectly match the canvas size.
The examples/web/common/app.dart
shows an example of a fixed virtual resolution approach. The browser will scale the canvas element itself, and your game renders to a fixed-size buffer.
Performance: Batching
Bullseye2D employs a technique called batch rendering to optimize performance. Instead of sending each drawing command (e.g., drawRect
, drawImage
) to the GPU immediately, it collects compatible commands into a "batch" and sends them all at once. This significantly reduces the overhead of communication between the CPU and GPU.
How Bullseye2D Helps:
- Automatic Batching: The engine tries to batch draw calls for you.
- Vertex Buffer: It maintains a vertex buffer (size configurable via
AppConfig.gfxBatchCapacityInBytes
). When this buffer is full, or a state change occurs, the current batch is flushed (sent to the GPU).
What is a "Flush"?
A flush means all currently batched geometry is drawn. gfx.flush()
is called:
- Automatically at the end of
App.onRender()
. - When the internal batching buffer is full.
- When a rendering state change occurs that prevents further batching. These include:
- Changing the active texture (
Texture.white
is a texture too!). - Changing the
BlendMode
(gfx.setBlendMode()
). - Changing the primitive type (e.g., switching from drawing quads/rectangles to lines).
- Note:
_PrimitiveType.triangleFan
and_PrimitiveType.lineStrip
will always cause a flush before drawing and after, as they are drawn withgl.drawArrays
in a non-indexed way that's distinct from the batched quads.
- Note:
- Changing the active texture (
How to Maximize Batching Performance:
- Minimize Texture Swaps:
- Use texture atlases (sprite sheets). Put many small images into one larger texture. This allows you to draw multiple different sprites without changing the bound texture, leading to larger batches.
Image.loadFrames
helps with this. - Group draw calls by texture: Draw all objects using texture A, then all objects using texture B, etc.
- Use texture atlases (sprite sheets). Put many small images into one larger texture. This allows you to draw multiple different sprites without changing the bound texture, leading to larger batches.
- Minimize Blend Mode Changes:
- Group draw calls by
BlendMode
. Draw all alpha-blended objects together, then all additive objects together, etc.
- Group draw calls by
- Minimize Primitive Type Changes:
- If you need to draw many lines and many quads, try to draw all lines first, then all quads (or vice-versa), if your game logic allows.
- Be Aware of Text:
BitmapFont
s use their own texture atlases. Drawing text will likely cause a texture swap if you were just drawing other images.
Understanding Batching Example
Less Optimal (more flushes):
// Texture A for player
gfx.drawImage(playerImage, ...); // Uses Texture A
// Texture B for enemy 1
gfx.drawImage(enemy1Image, ...); // FLUSH (Texture change from A to B)
// Text uses FontTexture
gfx.drawText(font, "Score: 100", ...); // FLUSH (Texture change from B to
// FontTexture)
// Back to Texture A for another player element (e.g., shield)
gfx.drawImage(playerShieldImage, ...); // FLUSH (Texture change from
// FontTexture to A)
Total Flushes (potentially): 3 internal + 1 end-of-frame = 4
More Optimal (fewer flushes):
// Assume playerImage and playerShieldImage are from the SAME texture atlas (Texture A)
// Assume enemy1Image and enemy2Image are from the SAME texture atlas (Texture B)
// Draw all Texture A items
gfx.drawImage(playerImage, ...);
gfx.drawImage(playerShieldImage, ...); // No flush if playerShieldImage uses Texture A
// Draw all Texture B items
gfx.drawImage(enemy1Image, ...); // FLUSH (Texture change from A to B)
gfx.drawImage(enemy2Image, ...); // No flush if enemy2Image uses Texture B
// Draw text
gfx.drawText(font, "Score: 100", ...); // FLUSH (Texture change from B to FontTexture)
Total Flushes (potentially): 2 internal + 1 end-of-frame = 3
The engine will warn you in the console if the batch buffer (gfxBatchCapacityInBytes
) is too small and causes frequent flushes due to capacity rather than state changes: vbo_buffer is full. consider enlarge it for better performance
.
Using a Custom Mouse Pointer
You can replace the default system mouse cursor with a custom image.
- Load your cursor image(s) using
resources.loadImage()
. Pay attention topivotX
andpivotY
as these will define the hotspot of your custom cursor. - Call
app.showMouse(Images? images, [int frame = 0])
. - To hide the cursor, call
app.hideMouse()
.
import 'package:bullseye2d/bullseye2d.dart';
class PointerDemo extends App {
@override
onCreate() async {
Images pointer = resources.loadImage("assets/gfx/pointer.png", pivotX: 0.0, pivotY: 0.0);
showMouse(pointer);
}
}
main() {
PointerDemo();
}
Writing a Custom Loading Screen
The App
class has an onLoading()
method that is called repeatedly while assets are loading (i.e., loader.done
is false
). You can override this to create a custom loading screen.
loader.percent
: Overall loading progress (0.0 to 1.0). This is an average of individual item progresses.loader.loaded
: Total bytes loaded across all items.loader.total
: Total bytes expected (can be 0 if sizes aren't known yet for some items).loader.allItemsFinished
: True if every individual item has completed (success or fail).loader.loadingSequenceFinished
: Becomes true when allresources.load*
calls in your initial setup (e.g.,onCreate
) have been made andonLoading
returns true whileallItemsFinished
is also true.
onLoading()
Behavior:
- This method is called instead of
onUpdate()
andonRender()
whileloader.done
is false. - To exit the loading screen and proceed to normal game loop (
onUpdate
/onRender
),onLoading()
must returntrue
ANDloader.allItemsFinished
must also be true. - If you return
true
butloader.allItemsFinished
is still false,onLoading
will continue to be called. - The default
onLoading
implementation inApp
shows spinning circles and a progress bar.
Example of a Simple Custom Loading Screen:
// In your App class:
@override
bool onLoading() {
gfx.clear(0.05, 0.05, 0.1, 1.0); // Dark blue background
// calculate and render the loading bar
gfx.drawRect(0, height / 2 - 20.0, width * loader.percent , 40);
// Condition to leave the loading screen:
// All items finished loading AND (for this example) player presses Space
if (loader.allItemsFinished) {
if (keyboard.keyHit(KeyCodes.Space)) {
log("Loading complete and space pressed, starting game!");
return true; // Proceed to onUpdate/onRender
}
}
return false;
}
Info If you don't want Bullseye's loader / onLoading at all, you can disable it with.
// in onCreate():
loader.isEnabled = false
Please be aware that assets might not be ready when onUpdate/onRender gets called. You can either either check the items if they are still loading or you can ignore it. In that case, Sounds or Images will not be rendered until fully loaded, but your logic will run fine without error.
Classes
- BitmapFont Graphics
- A font rendered from a pre-generated texture atlas.
- Graphics Graphics
- Provides the core 2D rendering capabilities for the Bullseye2D engine.
- Image Graphics
- A drawable image, typically a sub-region of a Texture.
- Texture Graphics
- Represents a Texture. Textures are reference-counted; use retain and dispose to manage their lifecycle.
- TextureFlags Graphics
- Defines bitmask flags to control Texture behavior, such as filtering,
Extensions
- ColorExtension on Color Graphics
- Provides utility methods for Color (Vector4) objects.
- ImageListExtension on Images Graphics
- Provides extension methods for lists of Image objects (Images).