flutter3d 0.5.2 copy "flutter3d: ^0.5.2" to clipboard
flutter3d: ^0.5.2 copied to clipboard

A 3D engine on Flutter GPU: scene graph, glTF/OBJ/.f3d loading, PBR, shadows, bloom, skinning.

0.5.2 #

  • Morph targets are drawn, on the GPU, on all three backends. A file's targets are packed by MorphTexture into an r32g32b32a32Float texture — 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: the in declarations of mesh.vert are the layout, one for every model, so deltas as attributes would mean a second vertex shader for each of six lighting models.
  • MorphState holds 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.morph is where it lives and MeshNode.morphWeights is the shortcut for a game putting a face into an expression by hand.
  • A weights track reaches it through MorphSink: an optional second list on AnimationPlayer, index-aligned with the targets it already has. Not a fourth setter on AnimationTarget — that is an abstract interface class in 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.
  • .f3d carries 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.reach tells 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 trap MeshNode.skinReach closes for skinning, found the same way.
  • A ray still finds the shape before the vertex stage moved it, for morphing as for skinning: Raycaster intersects CPU geometry. Now stated in Raycaster's own documentation, with MorphBlend named 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.setMorphWeights gives 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, playLayer and AnimationLayer — 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 an AnimationMask names and leaves the rest alone. Each layer carries its own playhead, wrap, speed and weight, with fadeTo to ramp the weight so a layer does not pop in, and where two layers want one joint the later one wins.
  • AnimationMask, and ModelDocument.maskUnder to 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, because AnimationTarget is three setters and no hierarchy on purpose: reading a parent would mean the animation layer depending on the scene graph, and through it on dart: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. apply now resolves one pose per joint and writes once, where it used to blend inside the per-path switch; the first test in animation_layer_test.dart is 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.z in an r16g16b16a16Float attachment, 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. See flutter3d_shaders 0.5.1 for the shader-side contract, which is the breaking half: a custom lit shader declaring FogInfo has to declare the new forward member.
  • viewAxisOf, beside toDepthRange and toFramebufferOrigin: 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, and SkyColour, take one object. AssetRequest and SkyLook can grow a field; a function type cannot grow a parameter without breaking every implementation. SkyLook is reused between calls, never held.
  • AssetSource is 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 failure ModelLoadRequest.decoders documents — so a source travels with the request instead, and must be sendable.
  • Projection is 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, ProceduralTexture and Projection are base. Extend them, do not implement them, so a member added later is inherited rather than missing. Eleven shapes and two procedural textures become final with them.
  • ModelNode.joints, Skeleton.joints, ModelAsset.skins and 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 to GraphicsDevice.maxAnisotropy on 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: a sampler in 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-floor joins 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, as SamplerOptions.anisotropy already was.
  • Renderer.relinkShaders: the engine's half of a hot reload. A LoadedShaderLibrary.refresh swaps 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 wearing ExampleStripes, a look compiled into the example's own bundle (example/shaders/, built by example/tool/build_shaders.sh) and loaded from bytes through GraphicsDevice.loadShaders before the renderer is built. GoldenScene.shaderBundle names the asset; the software backend draws it from the example's own Dart transcription.
  • Auto exposure. RenderSettings.autoExposure meters the frame: a luminance node writes the lit scene's log luminance into a 64×64 eight-bit target, GraphicsDevice.readback hands the bytes back a frame or two later, ExposureMeter averages the band between two percentiles of their histogram — the brightest fifth, so a dark corridor does not push a torchlit wall past white — and ExposureAdapter moves 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.exposure and Renderer.exposure say what that was. Off by default, since every golden is recorded at the setting's own number; auto-exposure is the one scene that turns it on. Its Impeller and software references are recorded here; the web one is not, because golden_web.sh holds 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 the ObjectId stage 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. Raycaster stays for a game, which wants an answer now and needs no frame.
  • FrameResourceIds.luminance and FrameResourceIds.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 in IdInfo.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 pickPixel with 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 toByteData off 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 — readbackRegionOf throws 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 and debugMeterFailures stayed 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: MASK says "there is nothing here" and is discarded in both passes, BLEND says "there is something here, faintly", and that is still a thing to click on. Renderer.pickPixel says 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.exposure defaults to RenderSettings.defaultExposure rather than to its own copy of the number.
  • example/lib/surface_probe.dart measures flutter_gpu's GpuImageSurface against the asImage() path present uses, and surface_probe_main.dart runs it the way conformance_main.dart runs 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 in example/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 _ldrFrames now says so, with what the probe measured, what the ring could do about it, and why it has not.
  • X-ray silhouettes. RenderSettings.xray names 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-xray joins the set on all three backends. Neither extra draw writes the surface buffer: they use LightingModel.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. ReflectionProbeNode is the scene seen from a point: six views drawn into the faces of a cube through ColorTarget.face, convolved into a roughness chain on the device through ColorTarget.mipLevel — the same lobe EnvironmentMap.prefilter builds on the host, as a full-screen pass per face per level — and read by the physical model of the nearest mesh whose radius it reaches, one probe per object and no blending. A kept probe is drawn once and again on invalidate(); refreshFaceEveryFrame redraws a face a frame after the first six. Scene.probes is the registry; probeFaceViewProjection is 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-car is 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 whose supportsRenderToMip is 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 — not Scene.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. isCaptured says 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.lightmapped picks a vertex stage that reads the colour attribute as a place in Material.lightmap, and every lit model adds albedo × lightmap beside 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-room is 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. uploadEncodedImage sniffs the format before dart:ui sees 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, once GraphicsDevice.supportsTextureFormat has said the device samples them.
  • uploadEncodedImage takes a report callback 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. ModelAsset and the material loader route it into their warnings.
  • glTF reads KHR_texture_basisu: a core source wins 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.dart is one sphere and one point light in seventy lines, opened through flutter3d_backend the way a new application would — and example.md puts those lines on pub.dev'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. encodeScene used 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. ModelAsset gained release, 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.dispose releases 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 its ui.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. pointNormalOffset now 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, depthPadding and the cube tile size could be set and nothing happened — measured on the crypt as two frames identical to the pixel under opposite casterFaces. StaticBakeKey and shouldBakeStatic hold 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 in flutter.assets they 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, with MaterialDecoder as 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.prefilter builds 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.
  • LookSettings in 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_hardware throughout, 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.
6
likes
160
points
509
downloads

Documentation

API reference

Publisher

verified publisherpleion.dev

Weekly Downloads

A 3D engine on Flutter GPU: scene graph, glTF/OBJ/.f3d loading, PBR, shadows, bloom, skinning.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#graphics #game-engine #rendering #gpu #flutter-gpu

License

MIT (license)

Dependencies

flutter, flutter3d_hardware, vector_math

More

Packages that depend on flutter3d