flutter3d 0.6.0
flutter3d: ^0.6.0 copied to clipboard
A 3D engine on Flutter GPU: scene graph, glTF/OBJ/.f3d loading, PBR, shadows, bloom, skinning.
0.6.0 #
- The engine's own code is unchanged, and the version moves anyway. Every
file under
lib/is byte for byte what 0.5.2 put on pub.flutter-io.cn; what moved is the demo this package ships beside it, the script that records its reference pictures, and two of the pictures. The number follows the whole set so that one number names one tree rather than the list of packages that happened to change — the publishing order inARCHITECTURE.md§16 is what makes that affordable. - The demo does not draw a golden until the model has landed. Everything the
scene decides — where the point lights stand, where the camera sits, how wide
the floor is — is decided when the load returns, and a frame drawn before that
sees every point light stacked at the origin. All the same distance from the
camera means all the same relevance, so the cube-shadow atlas handed its six
rows out in the order the lights were added, and then kept them: the allocator
leaves an incumbent where it is, because moving a row costs a full static
re-bake. The scene built to test that ranking was pinning scene order instead.
_stagedis the gate, held inbuildrather than by awaiting the load, since awaiting it still leaves one frame of an unstaged scene to go out. - Which frame the load lands on was a race, and it was measured rather than
guessed. Ten runs of
cube-shadow-manythrough WebGPU: in eight the load finished between 36 and 61 ms and the first frame arrived at about 90 ms; in the other two the load took 169 and 271 ms, four frames went out ahead of it, and those two put the teapots 1346 pixels of 172800 away from the other eight — two answers rather than a spread, which is what a latched allocation looks like. Through WebGL2 the load always lands behind the first frames, which is why that backend drew one picture where this one drew two. cube-shadow-crowdedandcube-shadow-manyare recorded again, in this package's Impeller set. They now show the rows the ranking chose rather than the rows the first frames latched, and compare at zero differing pixels.- A web build of the demo picks its browser backend from the URL, not from a
define:
?backend=webgpubeside the scene and the record-or-compare direction the page already carries. One dart2js run then serves both backends the way it already serves every scene, which is the whole saving of the browser golden stand; a define would be a build apiece. A name this build cannot make is refused rather than quietly substituted, because a reference set that describes the wrong device passes. tool/golden.shbuilds the application once and launches it once a scene. The scene used to arrive in a--dart-define, which is a compile-time input, so every scene was a fresh kernel compile filed under a fresh fingerprint directory — about 46 MB ofapp.dillapiece, and nothing has ever deleted one. Seven hundred and twenty-three of them, sixteen gigabytes, had collected in the main checkout by the time anybody measured, and a full pass could no longer finish on a machine with a couple of gigabytes free. The scene, the direction and the reference directory travel in the process environment now.--no-buildreuses what is already built.- What none of this changes: the renderer, the loaders, the scene graph and the public API are exactly 0.5.2's. A caller upgrading gets the same pictures out of the same calls, and the two reference images that moved are this package's own test data rather than anything a consumer draws.
0.5.2 #
- Morph targets are drawn, on the GPU, on all three backends. A file's
targets are packed by
MorphTextureinto anr32g32b32a32Floattexture — one column a vertex, three rows a target: positions, normals, tangents — uploaded once with the mesh and sampled by the vertex stage. A texture and not vertex attributes because the layout here is structural: theindeclarations ofmesh.vertare the layout, one for every model, so deltas as attributes would mean a second vertex shader for each of six lighting models. MorphStateholds the weights on the node, not on the geometry, so two copies of one model wear different expressions from one upload — the same split a skeleton already has.MeshNode.morphis where it lives andMeshNode.morphWeightsis the shortcut for a game putting a face into an expression by hand.- A weights track reaches it through
MorphSink: an optional second list onAnimationPlayer, index-aligned with the targets it already has. Not a fourth setter onAnimationTarget— that is anabstract interface classin a published package and a fourth member breaks every implementer — and a weight is not a transform anyway: it belongs to the mesh a node draws, which is a distinction glTF keeps too. Answered before the node lookup, so a face that never travels still morphs. - The rest weights a glTF node or mesh names are read, so a model arrives in the expression its author gave it. Up to eight targets blend at once; a file with more loads with a warning naming what was left out, rather than failing.
.f3dcarries the deltas and the weights in two sections of its own rather than by widening the mesh and surface records, so a build that predates them skips both and reads the same file as a model at rest. The container version is unchanged, which is what the section directory was for.- Bounds grow with the expression.
MorphState.reachtells a node how far its weights can put a vertex past the mesh's own box, so a morphed model is culled and shadowed against where it is rather than where it was authored. Without it a cube whose target slid it twelve metres drew nothing at all — the same trapMeshNode.skinReachcloses for skinning, found the same way. - A ray still finds the shape before the vertex stage moved it, for morphing
as for skinning:
Raycasterintersects CPU geometry. Now stated inRaycaster's own documentation, withMorphBlendnamed as the way to a deformed copy for a caller who needs one. The bounding volumes do follow. - A batch can wear a face per copy.
InstancedMeshNode.setMorphWeightsgives one instance its own weights, read in the vertex stage from a texture by instance id. Not in the instance record, where it would cost thirty-two bytes an instance to every batch in every game that morphs nothing — that record's size is part of the instanced vertex layout. The texture's own trade is that a weight changed is a texture rebuilt, since a texture is created with its contents and never written again, so a crowd whose faces differ costs nothing per frame and one whose faces move pays per frame. The rebuild happens only when a weight actually changed. - A crossfade mixes weights; a layer adds them. Two different questions and two answers. Fading between clips is a transition between whole performances, so halfway through it the face is halfway between the two expressions — the straight line a translation already takes. A layer is not a transition but a second thing happening at once, so a wince goes on top of a shout rather than instead of half of it, scaled by the layer's own weight and bounded at one so that two layers asking for most of a shape reach it instead of overshooting. A file whose own track asks for more than one still gets it: the ceiling belongs to the sum a layer makes.
- Animation layers: a clip over part of a skeleton while the base plays over
all of it.
AnimationPlayer.layers,playLayerandAnimationLayer— an upper body that reloads while the legs keep running, a monster that flinches without stopping to do it. A crossfade moves the whole skeleton; this moves the joints anAnimationMasknames and leaves the rest alone. Each layer carries its own playhead, wrap, speed and weight, withfadeToto ramp the weight so a layer does not pop in, and where two layers want one joint the later one wins. AnimationMask, andModelDocument.maskUnderto build one from a joint's name — every node at or under it. A set of indices rather than a walk up a parent chain, becauseAnimationTargetis three setters and no hierarchy on purpose: reading a parent would mean the animation layer depending on the scene graph, and through it ondart:ui, in everything that decodes a glTF file. A name the rig does not carry gives an empty mask rather than everything — a layer that does nothing rather than one that takes the skeleton over.- Override, not additive: a layer replaces the base's value for the joints it covers. A joint the base does not animate takes the layer's value outright, which is the rule the crossfade already followed for the same situation — there is nothing to blend from.
- No behaviour change without a layer.
applynow resolves one pose per joint and writes once, where it used to blend inside the per-path switch; the first test inanimation_layer_test.dartis the assertion that this moved nothing, because a reorganisation that shifted every pose by a hair would have shifted every golden in the repository.
0.5.1 #
- The surface buffer stops storing a depth its format cannot hold. Its
alpha held
gl_FragCoord.zin anr16g16b16a16Floatattachment, where a half-float step past twenty metres is wider than half a metre — so the occlusion pass, which decides a tap by subtracting two of those, decided whole bands of the frame by rounding and drew vertical stripes along the lines of equal depth on both GPU backends. It holds metres along the view axis now. Seeflutter3d_shaders0.5.1 for the shader-side contract, which is the breaking half: a custom lit shader declaringFogInfohas to declare the newforwardmember. viewAxisOf, besidetoDepthRangeandtoFramebufferOrigin: the world-space direction a view-projection looks along, read out of the matrix. For the places that have a matrix and no camera node — a frame graph contributor, a reflection probe's faces — which now have to agree with the scene pass about the axis the buffer's depths are measured along.- Screen-space occlusion and reflections reconstruct a world point as a ray crossed with a plane, unprojecting both ends of the pixel's ray. An orthographic camera's rays are parallel and meet nowhere, so the shorter version was right on a perspective camera and wrong on every isometric scene.
0.5.0 #
Breaking. Four callbacks widen, four types open, and nine collections stop being writable by whoever holds them.
AssetUriResolver, andSkyColour, take one object.AssetRequestandSkyLookcan grow a field; a function type cannot grow a parameter without breaking every implementation.SkyLookis reused between calls, never held.AssetSourceis open, and deliberately without a registry. A game whose assets live in an archive or behind a network cache writes its own source and passes it. A registry would be empty in the background isolate that does the reading — the failureModelLoadRequest.decodersdocuments — so a source travels with the request instead, and must be sendable.Projectionis open. Nothing ever switched over it; the matrix maths was always polymorphic. A skewed projection for a portal is the case that made the point.Shape,DerivedShape,ProceduralTextureandProjectionarebase. Extend them, do not implement them, so a member added later is inherited rather than missing. Eleven shapes and two procedural textures becomefinalwith them.ModelNode.joints,Skeleton.joints,ModelAsset.skinsand a glTF asset's buffers are unmodifiable. They are built by a load and read afterwards; a writer now breaks loudly, which is a change that can only be made before somebody starts writing.
0.4.3 #
RenderSettings.anisotropy. Taps a model's texture samplers may take along a foreshortened axis, applied at bind time to every material sampler that is trilinear and carries no level of its own, clamped toGraphicsDevice.maxAnisotropyon the way. One by default, so every picture is the bytes it was. A setting rather than a property of the asset because glTF has no way to say it: asamplerin the file names filters and wrap modes and nothing about taps. A level's brushes do not come through here — the bridge sizes their sampler to the device at load, and the setting leaves a sampler that already asks for taps alone.anisotropic-floorjoins the Impeller and software golden sets, with the WebGL reference recorded at merge: the demo's ground under the cube, stretched to the horizon, retextured with a checkerboard and its chain and seen from just above it. The setting is asserted to be one or more, asSamplerOptions.anisotropyalready was.Renderer.relinkShaders: the engine's half of a hot reload. ALoadedShaderLibrary.refreshswaps the code behind a stage and keeps the handle; a pipeline is a pair of stages linked, and the linked object still holds the old code until it is built again. This drops every pipeline the renderer linked — materials, shadows, sky, post, debug lines — so the next frame links the refreshed stages, at the cost of the first frame.loaded-shader, a golden in the Impeller and software sets: the teapot wearingExampleStripes, a look compiled into the example's own bundle (example/shaders/, built byexample/tool/build_shaders.sh) and loaded from bytes throughGraphicsDevice.loadShadersbefore the renderer is built.GoldenScene.shaderBundlenames the asset; the software backend draws it from the example's own Dart transcription.- Auto exposure.
RenderSettings.autoExposuremeters the frame: a luminance node writes the lit scene's log luminance into a 64×64 eight-bit target,GraphicsDevice.readbackhands the bytes back a frame or two later,ExposureMeteraverages the band between two percentiles of their histogram — the brightest fifth, so a dark corridor does not push a torchlit wall past white — andExposureAdaptermoves the exposure towards the answer in stops, at one rate climbing and another falling, between two limits. The composite exposes each frame with what the frame before was metered at;FrameResult.exposureandRenderer.exposuresay what that was. Off by default, since every golden is recorded at the setting's own number;auto-exposureis the one scene that turns it on. Its Impeller and software references are recorded here; the web one is not, becausegolden_web.shholds a fixed port for the whole of its run and so records when the branch lands. - Picking by pixel.
Renderer.pickPixel(u, v)asks the next frame which mesh is drawn at a point: that frame draws every visible mesh once more with theObjectIdstage into a frame-sized target, reads the one pixel back and answers with the node whose number came back — null for the clear colour, the batch for an instanced batch. Only on a frame somebody asked; otherwise the node is inactive and the graph culls it. A frame that fails after the question was asked answers it with the failure rather than never.Raycasterstays for a game, which wants an answer now and needs no frame. FrameResourceIds.luminanceandFrameResourceIds.objectIds, both frame outputs while their node is active, since their consumer is a readback the graph cannot see.- A pick through a hole answers with what is seen through it. A masked
material — glTF's
MASK— discards under its cutoff in the scene pass, and the id stage now discards the same fragments: it is handed the material's texture, cutoff and tint alpha inIdInfo.mask, so a click through a fence's hole answers with the thing behind the fence rather than the fence. Before, the id pass wrote every fragment and picked the plane the hole was cut in, which contradicted the one promise picking by pixel makes. - A frame that fails to build answers its questions too. The catch that
answers a pending
pickPixelwith the frame's failure covered the passes but not what came before them — an application node reading a name nothing writes fails in the graph's compile, and a question taken off the renderer there was on no list anybody would ever finish. Both catches now answer it, and the test is a node with a misspelled read. - The exposure meter asks once per answer. A readback in flight is not
joined by another: the luminance pass still runs every frame, the copy and
the download behind it are skipped until the last one has landed. On
flutter_gpu each answer is a
toByteDataoff a staging texture, and a meter asking every frame kept two or three of those in the air and a staging pool to match; an exposure that adapts over seconds cannot tell a reading every frame from one every other frame. - A device that refuses on the spot costs the question, not the frame.
Both readbacks are asked through
Future.sync, because a refusal is synchronous by contract —readbackRegionOfthrows before a future exists — and the backends refuse that way on their own account too: WebGL2 gives no fence for a lost context, flutter_gpu throws rather than returning false when a copy is turned down. Asked bare, the first of those came out of the luminance or the id node and took the whole picture down over a click, and the exposure meter's in-flight flag stayed set for ever, so it never asked the device again anddebugMeterFailuresstayed at nought — the one number that would have said so. - A blended surface is picked as though it were opaque, written down
rather than left to whichever way the pass fell. Glass, a translucent
marker, an additive flash answer with themselves and not with what is seen
through them:
MASKsays "there is nothing here" and is discarded in both passes,BLENDsays "there is something here, faintly", and that is still a thing to click on.Renderer.pickPixelsays so and a software-backend test holds it — a red pane at half alpha over a box, the click answering the pane while the picture shows the box through it. FrameResult.exposuredefaults toRenderSettings.defaultExposurerather than to its own copy of the number.example/lib/surface_probe.dartmeasures flutter_gpu'sGpuImageSurfaceagainst theasImage()pathpresentuses, andsurface_probe_main.dartruns it the wayconformance_main.dartruns the conformance suite: an application, because Flutter GPU needs Impeller and a headless test does not have it. In the example rather than in the backend because it reaches flutter_gpu directly, and an instrument is not part of a backend's API. It runs the ring twice — as the renderer has it, and holding the presented frame back one frame more, which is the promise the surface makes and the only version whose texture count compares with it — and its allocation control runs over the resize as well, so a pool that has not shrunk means the surface kept something rather than that the collector had not run. Its report and arithmetic are held by a headless test inexample/test.- The ring of finished frames says what its accounting tracks. The
callback that returns a frame texture to rotation fires when the
renderer's own GPU work is done, which is before the compositor has
sampled the texture; the note at
_ldrFramesnow says so, with what the probe measured, what the ring could do about it, and why it has not. - X-ray silhouettes.
RenderSettings.xraynames a layer, and every visible node on it is drawn twice more at the end of the scene pass: once to mark the stencil where it is visible, with no colour and no depth write, and once as a flat unlit colour where it fails the depth test and the stencil says no marked node's visible part is there. The stencil is what keeps the lit half of a half-hidden monster lit and one monster from painting through another; every mark is written before any paint. Both draws go through the same node encoder as the lit one, so skinning and batches come for free. Nothing is emitted when the mask is zero, the device has no stencil, or nothing visible is on the layer — every other golden is the bytes it was.stencil-xrayjoins the set on all three backends. Neither extra draw writes the surface buffer: they useLightingModel.xray, which is unlit with its second output compiled away, so a silhouette describes no surface to ambient occlusion or reflections — and it cannot, since it is drawn precisely where its node is behind something else. A software test holds one scene's surface buffer byte-identical with silhouettes on and off. - Reflection probes.
ReflectionProbeNodeis the scene seen from a point: six views drawn into the faces of a cube throughColorTarget.face, convolved into a roughness chain on the device throughColorTarget.mipLevel— the same lobeEnvironmentMap.prefilterbuilds on the host, as a full-screen pass per face per level — and read by the physical model of the nearest mesh whoseradiusit reaches, one probe per object and no blending. A kept probe is drawn once and again oninvalidate();refreshFaceEveryFrameredraws a face a frame after the first six.Scene.probesis the registry;probeFaceViewProjectionis the camera each face is drawn through, mirrored in x because the cube-map table is left-handed, and with y negated as well on a bottom-left backend — a half turn there rather than a mirror, so the winding flips on one origin and not the other.probe-caris a golden scene: recorded for Impeller and the software rasteriser, and recorded for the web set at the merge, which is when that set is written. Not built on a device whosesupportsRenderToMipis false; the material then reads what it read before. - A probe is read at its own strength.
ReflectionProbeNode.intensity, one by default, is what the surfaces that read a probe scale it by — notScene.ambientIntensity, which the sky environment shares with the flat ambient. A probe is the room's light measured, and a crypt whose ambient sits at six per cent would otherwise reflect its walls at six per cent. The strength travels in the slot the flat ambient already uses, since a draw reads one term or the other.isCapturedsays when every face stands at the current generation, so a level can hold its visibility culling until its kept probes have seen the whole of it. Measured on the racing demo's player car, a rolling probe costs the frame two milliseconds of build on Impeller and one in Chrome, and nothing in the raster half. - A lightmapped draw reads no probe, which is the same "one term or the other" rule the strength above follows, from the other side. A lightmap holds this surface's indirect light per texel; a probe's roughest level is a coarser answer to the same question, and the lit models add the lightmap on top of the environment rather than choosing — so a crypt wall that took both counted the room's bounce twice, once baked and once captured. The walls keep the bake, which is finer than a probe can be, and the probe lights what the bake does not reach: props, enemies, anything skinned or batched. What a wall gives up is its specular lobe, and a rough dielectric's is very nearly nothing.
- One whole cube a frame, across the scene. A level with a probe in every room used to capture all of them on the frame the level appeared — four probes is twenty-four views of a level the visibility culler is still holding off, inside one frame, at load. Now the first probe that has none takes the frame and the rest wait their turn, so a level stands over as many frames as it has rooms with a probe and the cost is paid in frames drawn without culling rather than in one very long one. A probe waiting its turn binds nothing rather than an allocation nobody has drawn into; one whose chain is merely stale keeps showing it. A rolling probe's single face is not rationed.
- The environment cube is sampled with a linear mip filter, so a roughness slides between levels rather than snapping, and so the backend that folds the mip filter into minification reads the level the shader named at all.
0.4.2 #
- Lightmaps.
MeshNode.lightmappedpicks a vertex stage that reads the colour attribute as a place inMaterial.lightmap, and every lit model addsalbedo × lightmapbeside its ambient, with a one-texel black bound where a material has none so nothing branches and every picture without a map is the bytes it was.lightmapped-roomis a golden in all three sets. - KTX2 is read. A pure-Dart reader for the container, and a port of Basis
Universal's ETC1S transcoder verified level by level against the encoder's
own unpack of three real files: mip chains and alpha slices included, run
on an isolate where there is one.
uploadEncodedImagesniffs the format beforedart:uisees the bytes; a Basis file arrives as RGBA8, and a file carrying its own BC, ETC2 or ASTC blocks is uploaded as those blocks, chain and all, onceGraphicsDevice.supportsTextureFormathas said the device samples them. uploadEncodedImagetakes areportcallback and says why an image was left out — a refused supercompression scheme, a family the device does not sample, a size that is not whole blocks.ModelAssetand the material loader route it into theirwarnings.- glTF reads
KHR_texture_basisu: a coresourcewins while it exists, and the extension's KTX2 is what a file that ships only that falls back to. The extension may be required without the file being refused. - Still refused by name: UASTC, Zstandard and ZLIB supercompression, texture arrays, cube maps, 3D textures.
0.4.1 #
- A minimal example.
example/lib/minimal_main.dartis one sphere and one point light in seventy lines, opened throughflutter3d_backendthe way a new application would — andexample.mdputs those lines on pub.flutter-io.cn's Example tab in place of the full model browser. A smoke test runs the file headless through the software fallback, so the claim in its comment is a thing CI checks rather than a thing the comment says.
0.4.0 #
- A pass's scene is lit by its own lights.
encodeSceneused to encode whatever scene it was handed with the frame's light buffer, gathered from the world at the top of the frame — so a view-model studio's two lights were never uploaded, every held weapon drew near-black, and the studio's light indices read shadow-atlas rows assigned to the world's torches. A scene other than the frame's now gets its own lights gathered into a pass buffer and the no-shadow table; the frame's own scene reuses the frame's buffers unchanged. - Everything the engine creates, something now releases.
ModelAssetgainedrelease, giving meshes and maps back with identity dedup — surfaces share meshes, materials share maps, and one image can sit in two slots of one material.Renderer.disposereleases the window-sized targets and drains both frames-in-flight rings instead of leaving them to a collector that, on WebGL2, deletes nothing. The texture upload path disposes itsui.Codec, which had leaked a decoder per decoded image.
0.3.0 #
- A point light's normal offset is measured in texels, not metres, which is
what it was always compensating for: a texel of the shadow map covers a patch
of surface, records it at one distance, and every fragment in that patch which
is not the point measured compares against a distance from somewhere else. The
patch is a solid angle and grows with range, so a flat 2 cm was right close to
a lamp and a third of what was needed ten metres out. What it looked like: the
floor under the golden teapot shadowing itself everywhere the lamp reached,
ending in a straight line where the floor's own edge projects — a straight
edge across a shadow in a scene with no straight edges in it.
pointNormalOffsetnow defaults to 1.5 texels. - The static cube-shadow bake now redraws when the settings that decide it
change. It redrew when a light took another's atlas row and at no other
time, so
casterFaces,depthPaddingand the cube tile size could be set and nothing happened — measured on the crypt as two frames identical to the pixel under oppositecasterFaces.StaticBakeKeyandshouldBakeStatichold the rule, and exclude everything the lookup reads: a bias must not cost a re-bake. - Six point lights may cast at once, not four. The crypt hangs six torches, so two of them lit their corner and cast nothing — and which two changed as the player walked, because the rows go to whatever matters most from where the camera is. Two torches side by side behaving differently is what a player reads as "the shadows are broken". Affordable only after the line below: at the old cube tile the extra rows would have cost 100 MB, and they cost 25.
ShadowSettings.cubeResolution, defaulting to 512, so a cube face no longer inherits the cascade's tile size. It did, and the atlas is thirty-six tiles: a game asking for a 1024 sun was allocating 201 MB per atlas and there are two. The golden sets did not move when it changed.- The Khronos sample models are no longer this package's assets. They moved
to
flutter3d_samples, a dev dependency here: declared influtter.assetsthey were 4.1 MB in every application that depended on the engine — a third of the shooter's web asset payload, none of it ever loaded — and four fifths of this package's own archive. The decoder tests read them from disk as before. .fmat: a material as a file of its own, withMaterialDecoderas the boundary for formats the engine does not ship. Consulted before the built-in reader, so a project can replace it rather than only add to it.- Image-based lighting:
EnvironmentMap.prefilterbuilds a specular chain and a diffuse level from a cube map or from sky settings, over a fixed golden-angle spiral so two independently written backends agree exactly. LookSettingsin the composite pass: colour grading, vignette, grain and chromatic aberration.- Materials carry a parameter block, numeric parameters and extra texture slots, so an application's own shader has somewhere to read from.
0.2.0 #
- Cascaded directional shadows, cube shadows for point and spot lights into an atlas, screen-space reflections and ambient occlusion, a sky and two-colour ambient light.
- A frame graph that culls the passes a frame does not need, and a frame resource ring that returns a target when the GPU says so rather than a fixed number of frames later.
- Instancing and CPU-built mip chains, uploaded level by level because no backend can be trusted to generate the same ones.
- Written against
flutter3d_hardwarethroughout, so the backend is a value a caller hands in rather than a compile-time choice.
0.1.0 #
- A CPU geometry layer with no GPU in it: vertex layouts, meshes, and shapes as values — surfaces of revolution generate the sphere, cylinder, cone, torus, capsule and disc.
- A scene graph whose transforms are held by version counters rather than dirty flags, so a stale world matrix is structurally impossible.
- glTF 2.0 / GLB and Wavefront OBJ behind one document abstraction, plus
.f3d, the engine's own container: the teapot loads in 1.1 µs instead of 4.54 ms, and the two render pixel for pixel. - Six lighting models, each a pre-built shader; directional shadows, an HDR pipeline with tone mapping and bloom, skinning, animation, BVH culling, LOD and CPU picking.