Assets over a network
A game that loads assets from a server is downloading somebody else’s bytes
and handing them to a decoder — a decoder written in C, for several of the
formats. So the network side of orblit_asset is built the other way round
from most download code: every setting is a limit rather than a permission,
and the cheapest defence is not fetching the thing at all.
import 'package:orblit_asset/orblit_asset.dart';
AssetFetcher fetcher() => AssetFetcher( origin: AssetOrigin.parse( 'https://cdn.example.com/game/', policy: const FetchPolicy(maxBytes: 64 << 20), ), transport: HttpTransport(),);An origin is where assets live and what’s allowed from there. A transport is the thing that actually talks to a server. A fetcher is the queue, the retries, the verification and the cache in front of both.
The policy refuses first
Section titled “The policy refuses first”FetchPolicy defaults to the cautious answer for everything.
| Default | What it stops | |
|---|---|---|
hosts |
none besides the origin’s own | an asset path that redirects somewhere else |
allowInsecure |
false |
a texture replaced in flight on plain HTTP |
maxBytes |
256 MiB | a download that never ends |
maxPixels |
64 Mpx | a decode bomb |
hosts is matched exactly and case-insensitively. cdn.example.com does not
allow evil.cdn.example.com, because a suffix match is how an allow-list
becomes an allow-anything. Add the second place a project keeps things with
policy.allowing(['assets.example.com']).
maxBytes is checked twice: against the length the server declares, before
the body is read, and again as the body arrives. A server that says one thing
and sends another is exactly the case worth catching, and a server that
declares no length at all is the case where only the second check exists.
maxPixels is separate from maxBytes and not derivable from it. A
64,000 × 64,000 PNG of flat colour compresses to a few hundred kilobytes and
decodes to sixteen gigabytes. A size limit stops a slow download; only a
dimension limit stops that.
Anything refused throws FetchRefused, before a connection is opened.
A manifest is what makes bytes trustworthy
Section titled “A manifest is what makes bytes trustworthy”An AssetId is what a project calls a thing — models/robot.glb. A
ContentHash is what the thing actually is, byte for byte. An
AssetManifest is the one place the two meet, and handing one to the fetcher
is what turns “these bytes arrived” into “these are the right bytes”.
import 'package:orblit_asset/orblit_asset.dart';
AssetFetcher verified(String manifestJson) { final load = AssetManifest.read(manifestJson); for (final problem in load.problems) { print(problem); }
return AssetFetcher( origin: AssetOrigin.parse('https://cdn.example.com/game/'), transport: HttpTransport(), manifest: load.manifest, );}Without a manifest nothing is verified, and the fetcher says so rather than
pretending the bytes are vouched for. With one, a body whose hash doesn’t
match throws FetchCorrupt naming what was wanted and what turned up — and
the bytes never reach a decoder.
Keeping names and hashes apart is the whole design. A name is what people type and scenes store, so it has to stay put while the file behind it changes. A hash is what caches and downloads can trust, so it has to change whenever a single byte does. Anything that mixes the two either re-downloads what it already has or keeps what it should have thrown away.
Fetching
Section titled “Fetching”import 'dart:typed_data';
import 'package:orblit_asset/orblit_asset.dart';
Future<Uint8List> load(AssetFetcher fetcher) { final job = fetcher.fetch( AssetId.parse('models/robot.glb'), urgency: FetchUrgency.onScreen, onProgress: (p) => print('${p.received} of ${p.total ?? '?'}'), );
// job.cancel() closes the connection and fails job.bytes with // FetchCancelled, which is what makes it safe to start one per visible // thing and drop them when the scene changes. return job.bytes;}Urgency is the queue’s ordering, not a priority hint that gets ignored:
onScreen is something being looked at now, soon is the next room, and
eventually is warming the cache and should never delay either of the
others. Four downloads run at once by default.
A failed attempt is retried three times with a jittered backoff, but only
when retrying could work. A 5xx, a 408, a 425 or a 429 is a server having a
moment. A 404 or a 403 is an answer: retrying it is a slower way to fail, and
on a metered connection an expensive one. What’s left over throws
FetchFailed, carrying the URL, how many attempts were made and the cause.
A download that stops partway resumes from where it stopped rather than starting again, and a copy already held is revalidated with its ETag — which costs a round trip and no body when it’s still current. That’s the difference between a cold start and a warm one on a connection where the bytes are the expensive part.
fetcher.read(id) is fetch(id).bytes for when there’s nothing to cancel or
watch.
As a source, and in stages
Section titled “As a source, and in stages”NetworkAssetSource makes a fetcher into an ordinary AssetSource, which
is what lets it be a layer under the ones already on the device.
import 'package:orblit_asset/orblit_asset.dart';
AssetSource layered(AssetFetcher fetcher, AssetSource bundled) => LayeredAssetSource([bundled, NetworkAssetSource(fetcher)]);A 404 becomes AssetNotFound, which is the only thing LayeredAssetSource
moves to the next source on. A refused host, a hash that doesn’t match, a
connection that never came back — all passed on untouched, because “the
server hasn’t got it” and “the server couldn’t be reached” must not lead to
the same place. Quietly serving something older for the second hides an
outage.
When there needs to be something on screen before the real asset lands:
import 'package:orblit_asset/orblit_asset.dart';
Stream<AssetStage> wall(AssetFetcher fetcher) => fetcher.fetchInStages( AssetId.parse('textures/wall.ktx2'), standIn: AssetId.parse('textures/wall_small.ktx2'), roughSize: 256,);At most two stages, and often one. The stand-in is only handed over if it arrived, 100 ms have passed, and the real asset hasn’t turned up in the meantime — which on a warm cache it will have, so a second launch shows no stand-in at all rather than flashing one for a frame. That flash is why the delay exists: substituting a blurry texture for a sharp one is worth it while the wait is long, and worse than nothing when the wait is three frames.
A mipped .ktx2 gets a third stage for free: the coarse
levels, built out of the front of the file while the rest is still arriving.
No extra request and no extra byte — those bytes were coming anyway — and
because it’s the same asset rather than a substitute, it supersedes the
stand-in and is never followed by one.
Cancelling the subscription cancels both fetches.
Testing without a network
Section titled “Testing without a network”MapTransport answers from a map and understands enough of the protocol to
exercise the code that depends on it: ETags it answers 304 to, ranges it
honours, and a list of faults to hand out before it starts succeeding —
including a reply that sends most of a large file and then drops, which is
the failure resuming exists for and the one a test that only fails early
never sees.
import 'dart:typed_data';
import 'package:orblit_asset/orblit_asset.dart';
AssetFetcher offline(Uint8List robot) => AssetFetcher( origin: AssetOrigin.parse('https://example.test/game/'), transport: MapTransport({ Uri.parse('https://example.test/game/models/robot.glb'): TransportPage( robot, etag: 'v1', faults: [const Fault.status(503), const Fault.thrown()], ), }),);transport.sent is every request in order, which is how you assert that the
second launch sent an if-none-match and downloaded nothing.
What isn’t there yet
Section titled “What isn’t there yet”- Nothing serves this yet. There’s no Orblit-side host or CDN layout — you point an origin at a directory you publish however you already publish things, with a manifest beside it.
- The renderer doesn’t fetch on its own. Streaming a splat capture’s subset over the network, in particular, is renderer work rather than network work and isn’t wired up.
- A browser cache is used where one exists, through
CacheStorage, but there’s no eviction policy across platforms — aContentStoregrows until something clears it.
