Very Easy Start with 3D Engine in Flutter
With no access to the GPU, we’ll have to do this old-school way. I first learned to code on an Atari ST in the late ’80s and early ’90s. Back then, we didn’t have frameworks like OpenGL or Metal; there was no “near-direct access to the graphics processing unit.” So, like our fathers before us, we built 3D engines the hard way, from scratch, using maths.
Although Flutter uses OpenGL to render its widgets, it doesn’t provide us with an API to access the underlying hardware. If we can’t use the GPU, we’ll have to do our calculations on the CPU instead. A CPU renderer won’t be fast, but it’s simple to understand, easy to build, and can still be used for some interesting effects.
Let’s get started.
First, we’ll need a 3D model
While there are lots of file formats for 3D data, the simplest is probably the Wavefront Object Format. It contains; a list of points, a list of faces, and a few references to things like color and texture, all as human-readable text.
First, we’ll include an object (.obj) and material (.mtl) file in our pubspec.yaml, then we load them as strings using Flutter’s global rootBundle.
// flutter_rootbundle.dart
void initState() {
rootBundle.loadString(widget.path).then((value) {
setState(() {
model.loadFromString(value);
});
});
super.initState();
}
Once we have the strings, we’ll parse them into objects. The result is a list of points in 3D space and a list of triangles that reference those points.
// flutter_3dmodel.dart
class Model() {
List<Vector3> verts;
List<List<int>> faces;
Model() {
verts = List<Vector3>();
faces = List<List<int>>();
}
void loadFromString(String string) {
List<String> lines = string.split("\n");
lines.forEach((line) {
// Parse a vertex
if (line.startsWith("v ")) {
var values = line.substring(2).split(" ");
verts.add(Vector3(
double.parse(values[0]),
double.parse(values[1]),
double.parse(values[2]),
));
// Parse a face
else if (line.startsWith("f ")) {
var values = line.substring(2).split(" ");
faces.add(List.from([
int.parse(values[0].split("/")[0]),
int.parse(values[1].split("/")[0]),
int.parse(values[2].split("/")[0]),
]));
}
});
}
}
Now we’ll need some Maths
We’ve got our model; now we need to rotate, scale, and transform it. The Intenet has plenty of tutorials on 3D graphics, I won’t cover everything here, but the basics go like this:
For every point in our model, we rotate, translate, and scale it around its origin. We then sort the faces based on their distance from the camera and draw them, starting with those furthest away.
We do all of this in the paint()
method of a stateful widget, one that extends the CustomPainter()
class.
// flutter_paintmodel.dart
@override
void paint(Canvas canvas, Size size) {
// Rotate and translate the vertices
verts = List<Math.Vector3>();
for (int i = 0; i < model.verts.length; i++) {
verts.add(_calcVertex(Math.Vector3.copy(model.verts[i])));
}
// Sort
var sorted = List<Map<String, dynamic>>();
for (var i = 0; i < model.faces.length; i++) {
var face = model.faces[i];
sorted.add({
"index": i,
"order": Utils.zIndex(verts[face[0] - 1], verts[face[1] - 1], verts[face[2] - 1])
});
}
sorted.sort((Map a, Map b) => a["order"].compareTo(b["order"]));
// Render
for (int i = 0; i < sorted.length; i++) {
var face = model.faces[sorted[i]["index"]];
var color = model.colors[sorted[i]["index"]];
_drawFace(canvas, face, color);
}
}
The critical call here is _calcVertex()
; it’s the one doing the bulk of the maths.
// flutter_calcvertex.dart
Math.Vector3 _calcVertex(Math.Vector3 vertex) {
var trans = Math.Matrix4.translationValues(_viewPortX, _viewPortY, 1);
trans.scale(_zoom, -_zoom);
trans.rotateX(Utils.degreeToRadian(angleX));
trans.rotateY(Utils.degreeToRadian(angleY));
trans.rotateZ(Utils.degreeToRadian(angleZ));
return trans.transform3(vertex);
}
We use a 4×4 matrix to rotate, translate, and scale our vertex in a single pass. Aside from a few lighting calculations, all the vector math in this project uses the Dart vector math library.
We’re ready to draw our model
We’re finally ready. For each face, we calculate the lighting, derive a color value based on its brightness, and draw the face using Dart’s Path()
command.
// flutter_drawface.dart
void _drawFace(Canvas canvas, List<int> face, Color color) {
// Reference the rotated vertices
var v1 = verts[face[0] - 1];
var v2 = verts[face[1] - 1];
var v3 = verts[face[2] - 1];
// Calculate the surface normal
var normalVector = Utils.normalVector3(v1, v2, v3);
// Calculate the lighting
Math.Vector3 normalizedLight = Math.Vector3.copy(light).normalized();
var jnv = Math.Vector3.copy(normalVector).normalized();
var normal = Utils.scalarMultiplication(jnv, normalizedLight);
var brightness = normal.clamp(0.0, 1.0);
// Assign a lighting color
var r = (brightness * color.red).toInt();
var g = (brightness * color.green).toInt();
var b = (brightness * color.blue).toInt();
var paint = Paint();
paint.color = Color.fromARGB(255, r, g, b);
paint.style = PaintingStyle.fill;
// Paint the face
var path = Path();
path.moveTo(v1.x, v1.y);
path.lineTo(v2.x, v2.y);
path.lineTo(v3.x, v3.y);
path.lineTo(v1.x, v1.y);
path.close();
canvas.drawPath(path, paint);
}
Adding user controls
Our final task is allowing the user to rotate the model. Flutter makes this easy with its dragUpdateHandler
.
// flutter_dragupdate.dart
_dragX(DragUpdateDetails update) {
setState(() {
angleX += update.delta.dy;
if (angleX > 360)
angleX = angleX - 360;
else if (angleX < 0) angleX = 360 - angleX;
});
}
_dragY(DragUpdateDetails update) {
setState(() {
angleY += update.delta.dx;
if (angleY > 360)
angleY = angleY - 360;
else if (angleY < 0) angleY = 360 - angleY;
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
child: CustomPaint(
painter: _ObjectPainter(widget.size, model, angleX, angleY, angleZ, widget.zoom),
size: widget.size,
),
onHorizontalDragUpdate: (DragUpdateDetails update) => _dragY(update),
onVerticalDragUpdate: (DragUpdateDetails update) => _dragX(update),
);
}
Final Thoughts
This article’s not a serious attempt at a 3D engine, more an exercise to learn more about Flutter and Dart. I’ve shared it here in response to a few forum posts asking for something similar. You can find the full code here: GitHub Repo
Lee Luong – Co Founder & CEO