3D Game DevelopmentBuild Your First 3D SceneOn this pageBuild Your First 3D Scene In the Dora engine, creating a 3D model node is just as simple as creating a 2D sprite: a single line of code gives you back a renderable, interactive game object. A model on its own is enough to put something on screen. But to turn that "something" into a scene that actually reads well, you usually add two more pieces on top: a camera that views the model from the right place in world space, and a light so its surfaces are not dark and flat. None of this is required to make Dora render - it's what raises the quality of what gets rendered. This tutorial adds each piece one at a time, so you can see what changes at every step. By the end you'll have a single lit duck on screen, and you'll know exactly which line of code produced which part of the picture. Tutorial goalBy the end, you will see a readable 3D model through a Camera3D and a directional light. Before you start Create a Dora project (TypeScript recommended) and open it in the Web IDE. Put a glTF 2.0 model at Assets/Model/Duck.glb. A .glb file is the easiest starting point because it packages its geometry, textures, and any embedded animations together in one file. This tutorial uses the default 3D entry view: Director.entry. If you use your own asset, only change the Model3D path for now. Keep the model near its authoring origin (the position it was exported at) until you finish the first three steps. 1. Create a model The smallest possible 3D scene in Dora is one line of code. Model3D loads a glTF file and gives you back a Node3D you can place in the scene. Geometry, textures, and animations are all loaded for you. TypeScriptLuaYueScriptTealinit.tsimport {Model3D} from "Dora";Model3D("Assets/Model/Duck.glb");init.lualocal _ENV = DoraModel3D("Assets/Model/Duck.glb")init.yue_ENV = DoraModel3D "Assets/Model/Duck.glb"init.tllocal Model3D <const> = require("Model3D")Model3D("Assets/Model/Duck.glb") When you run this, you might see the duck right away, or you might see it but very small and oddly placed. That's expected. Dora is still using its default 2D camera, which treats positions as pixel offsets, so a 3D model authored in metres can end up looking tiny. The point of this step is just to confirm that the model loaded and is now part of the scene; we'll fix the view next. Model3D returns a Node3D. Any Node3D that isn't attached to a parent gets mounted automatically under the 3D scene root, which is Director.entry. Automatic mounting is convenient for quick experiments, but when you care about hierarchy, you should attach the model yourself so the parent-child relationship is obvious from the code: TypeScriptLuaYueScriptTealinit.tsimport {Director, Model3D} from "Dora";const model = Model3D("Assets/Model/Duck.glb");if (model) { Director.entry.addChild(model);}init.lualocal _ENV = Doralocal model = Model3D("Assets/Model/Duck.glb")if model then Director.entry:addChild(model)endinit.yue_ENV = Doraif model := Model3D "Assets/Model/Duck.glb" Director.entry\addChild modelinit.tllocal Director <const> = require("Director")local Model3D <const> = require("Model3D")local Model3DType = require("Model3D").Typelocal model = Model3D("Assets/Model/Duck.glb") as Model3DTypeDirector.entry:addChild(model) The if (model) check matters in real projects because Model3D returns nothing when the file can't be loaded (wrong path, corrupt file, unsupported format). Handling that case explicitly is better than letting the rest of the code fail in a confusing way later. Use the one-line form when you just want something on screen quickly. Use the explicit addChild form whenever the model should sit beneath another Node3D (for example, a weapon under a character's hand) or whenever you want the hierarchy to be readable at a glance. 2. Switch to a 3D camera The model is now in the scene, but you're viewing it through a 2D camera. To see it properly, you need a Camera3D. A 3D camera has a position in world space, looks at a specific point, and respects depth, so objects further away appear smaller. That's what makes a 3D scene feel three-dimensional. TypeScriptLuaYueScriptTealinit.tsimport {Camera3D, Director, Vec3} from "Dora";const camera = Camera3D();camera.lookAt(Vec3(3, 2, 5), Vec3(0, 0, 0));Director.pushCamera(camera);init.lualocal _ENV = Doralocal camera = Camera3D()camera:lookAt(Vec3(3, 2, 5), Vec3(0, 0, 0))Director:pushCamera(camera)init.yue_ENV = Doracamera = Camera3D!camera\lookAt Vec3(3, 2, 5), Vec3(0, 0, 0)Director\pushCamera camerainit.tllocal Camera3D <const> = require("Camera3D")local Director <const> = require("Director")local Vec3 <const> = require("Vec3")local camera = Camera3D()camera:lookAt(Vec3(3, 2, 5), Vec3(0, 0, 0))Director:pushCamera(camera) Three things are happening here: Camera3D() creates a new 3D camera node. It is itself a Node3D, so you can move, rotate, and parent it like any other node. lookAt(eye, target) points the camera. The first argument is the camera's position in world space; the second is the point it should look at. Here the camera sits at (3, 2, 5) and aims at the origin (0, 0, 0), which is where the duck was placed. Director.pushCamera(camera) makes this camera the active one. Until you call this, the engine keeps using whatever camera was active before, which by default is the 2D camera. The numbers passed to Vec3 are world units, which you can think of as metres. So Vec3(3, 2, 5) means "3 metres to the right, 2 metres up, 5 metres forward" relative to the origin. This is one of the biggest differences from 2D, where positions are pixel offsets from the center of the screen. 3. Add a directional light The model is now framed correctly, but it probably still looks dark or flat. That's because 3D rendering shades each pixel based on the light that reaches it, and so far the scene has no light at all. Let's add a DirectionalLight3D, which simulates a distant light source like the sun: all of its rays run in parallel from one direction, so every surface in the scene is lit the same way regardless of where it is. TypeScriptLuaYueScriptTealinit.tsimport {DirectionalLight3D} from "Dora";const light = DirectionalLight3D();light.intensity = 3;init.lualocal _ENV = Doralocal light = DirectionalLight3D()light.intensity = 3init.yue_ENV = Doralight = DirectionalLight3D!light.intensity = 3init.tllocal DirectionalLight3D <const> = require("DirectionalLight3D")local light = DirectionalLight3D()light.intensity = 3 A directional light has no position; imagine it as infinitely far away, like the sun. It only has a direction (set by rotating the node) and a brightness. Setting intensity = 3 makes it three times brighter than the default, which is enough to read the model clearly. Like the model, an unattached light is mounted automatically under Director.entry. You can also set light.angleX and light.angleY to control the direction the light comes from, and light.color to tint it. We'll cover those in the next tutorial, along with shadows. Run the project. You should now see a clearly framed, lit duck. Troubleshooting What you seeWhat to checkThe model is tiny or missing in step 1Continue to step 2; the default camera is 2D, so a 3D model often looks wrong until you switch.A blank view after step 2Make sure Director.pushCamera(camera) ran and the camera looks at the model origin.A very dark model after step 3Confirm the directional light was created, then continue to the next tutorial for materials and environment lighting. Complete examples The complete scripts below pull everything in this tutorial together, with a slow rotation added so you can see the duck from all sides and a view.stats check so you can confirm the scene actually rendered something. They've been verified by the Dora engine. TypeScriptLuaYueScriptTealinit.tsimport {App, Camera3D, Color3, Content, DirectionalLight3D, Director, Model3D, Vec3, threadLoop} from "Dora";const view = Director.entry;view.setEnvironmentMap("");view.setEnvironmentIntensity(0, 0, 1);const camera = Camera3D();camera.lookAt(Vec3(4, 3, 6), Vec3(0, 0.7, 0));Director.pushCamera(camera);const light = DirectionalLight3D();light.color = Color3(0xffffff);light.intensity = 4;light.angleX = -45;light.angleY = 25;view.addChild(light);const model = Model3D("Assets/Model/Duck.glb");if (!model) throw new Error("failed to load Duck.glb");view.addChild(model);// Spin slowly so you can see the model from every angle,// and after 1.5s write a small pass/fail marker you can check externally.let elapsed = 0;let checked = false;threadLoop(() => { model.angleY += App.deltaTime * 30; elapsed += App.deltaTime; if (!checked && elapsed > 1.5) { checked = true; const stats = view.stats; const passed = stats.drawCalls > 0 && stats.visibleVisuals > 0; const status = passed ? "PASS" : "FAIL"; Content.save("first-3d-scene.result", `status=${status} draws=${stats.drawCalls} visible=${stats.visibleVisuals}`); } return false;});init.lualocal _ENV = Doralocal view = Director.entryview:setEnvironmentMap("")view:setEnvironmentIntensity(0, 0, 1)local camera = Camera3D()camera:lookAt(Vec3(4, 3, 6), Vec3(0, 0.7, 0))Director:pushCamera(camera)local light = DirectionalLight3D()light.color = Color3(0xffffff)light.intensity = 4light.angleX = -45light.angleY = 25view:addChild(light)local model = Model3D("Assets/Model/Duck.glb")if not model then error("failed to load Duck.glb")endview:addChild(model)-- Spin slowly so you can see the model from every angle,-- and after 1.5s write a small pass/fail marker you can check externally.local elapsed = 0local checked = falsethreadLoop(function() model.angleY = model.angleY + App.deltaTime * 30 elapsed = elapsed + App.deltaTime if not checked and elapsed > 1.5 then checked = true local stats = view.stats local passed = stats.drawCalls > 0 and stats.visibleVisuals > 0 local status = passed and "PASS" or "FAIL" Content:save("first-3d-scene.result", string.format( "status=%s draws=%d visible=%d", status, stats.drawCalls, stats.visibleVisuals)) end return falseend)init.yue_ENV = Doraview = Director.entryview\setEnvironmentMap ""view\setEnvironmentIntensity 0, 0, 1camera = Camera3D!camera\lookAt Vec3(4, 3, 6), Vec3(0, 0.7, 0)Director\pushCamera cameralight = DirectionalLight3D!light.color = Color3 0xfffffflight.intensity = 4light.angleX = -45light.angleY = 25view\addChild lightmodel = Model3D "Assets/Model/Duck.glb"error "failed to load Duck.glb" unless modelview\addChild modelelapsed = 0checked = falsethreadLoop -> model.angleY += App.deltaTime * 30 elapsed += App.deltaTime if not checked and elapsed > 1.5 checked = true stats = view.stats passed = stats.drawCalls > 0 and stats.visibleVisuals > 0 Content\save "first-3d-scene.result", string.format( "status=%s draws=%d visible=%d", if passed then "PASS" else "FAIL", stats.drawCalls, stats.visibleVisuals) falseinit.tllocal App = require("App")local Camera3D = require("Camera3D")local Color3 = require("Color3")local Content = require("Content")local DirectionalLight3D = require("DirectionalLight3D")local Director = require("Director")local Model3D = require("Model3D")local Model3DType = require("Model3D").Typelocal Vec3 = require("Vec3")local threadLoop = require("threadLoop")local view = Director.entryview:setEnvironmentMap("")view:setEnvironmentIntensity(0, 0, 1)local camera = Camera3D()camera:lookAt(Vec3(4, 3, 6), Vec3(0, 0.7, 0))Director:pushCamera(camera)local light = DirectionalLight3D()light.color = Color3(0xffffff)light.intensity = 4light.angleX = -45light.angleY = 25view.scene:addChild(light)local model = Model3D("Assets/Model/Duck.glb") as Model3DTypeview.scene:addChild(model)local elapsed: number = 0local checked = falsethreadLoop(function(): boolean model.angleY = model.angleY + App.deltaTime * 30 elapsed = elapsed + App.deltaTime if not checked and elapsed > 1.5 then checked = true local stats = view.stats local passed = stats.drawCalls > 0 and stats.visibleVisuals > 0 Content:save("first-3d-scene.result", string.format( "status=%s draws=%d visible=%d", passed and "PASS" or "FAIL", stats.drawCalls, stats.visibleVisuals)) end return falseend) Summary You now have the three core pieces of any 3D scene working together: Model: a Model3D loads a glTF file and becomes a Node3D you can move, rotate, and parent. Unattached nodes are mounted automatically under Director.entry. Camera: a Camera3D views the scene from a specific position in world space. lookAt(eye, target) points it, and Director.pushCamera makes it active. Light: a DirectionalLight3D simulates sunlight and shades the surfaces so they aren't black. Its intensity controls brightness. Each piece on its own isn't enough: a model with no camera is invisible, a camera with no light shows a dark scene. With all three in place, you have a foundation you can build on - which is what the rest of this series of tutorials does. Try it yourself Keep a reference to the model, move it to Vec3(1, 0, 0), then update the camera's lookAt target to Vec3(1, 0, 0) as well. Run it. Now try the same model move but leave the camera target at the origin. The difference between the two results is the difference between "moving an object" and "moving the camera's focus" - a distinction that will matter throughout the rest of these tutorials. Next tutorial Continue with materials, lights, and shadows to give the duck a ground to cast shadows onto and to customize the way its surfaces look.