# Qwen3.5-4B-GLB-3D-Coder: 417 exported GLBs, every one valid, almost none to scale

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.satyajit.si/articles/qwen35-4b-glb-3d-coder
> date: 2026-11-09
> tags: qwen, threejs, gltf, 3d, fine-tuning, datasets, game-dev

A [post by @dextune](https://x.com/dextune/status/2117091981973434415) on 2 November says CCORE, the author of [Qwen3.5-4B-3D-Coder](/articles/qwen35-4b-3d-coder), has a second fine-tune out, this one for game assets. The model turns a text prompt into a standalone HTML and JavaScript page built on Three.js, and each page has a GLB export button, so the asset can be imported into Godot. The post says most generations work on the first attempt, especially with English prompts, and that the full weights and the training dataset would follow once training finished.

Both have since followed. The model is [`ccore/Qwen3.5-4B-GLB-3D-Coder`](https://huggingface.co/ccore/Qwen3.5-4B-GLB-3D-Coder) and the data is [`ccore/ThreeJS-3D-GLB-Assets`](https://huggingface.co/datasets/ccore/ThreeJS-3D-GLB-Assets). I wrote up the first model from its weights, data and technical report; this piece does not repeat the architecture or the KV-cache arithmetic, which have not changed. It asks a narrower question: what does a game developer actually get out of this, and is it usable?

Labels, as before. **Measured** means I ran code on a file and this is what it printed. **Reported** means the author says so and I did not check. **Reasoned** means arithmetic or inference on numbers of either kind.

<ModelCard repo="ccore/Qwen3.5-4B-GLB-3D-Coder" />

## What the model outputs

The tags say `glb`, so it is worth being precise. The model does not emit GLB bytes, glTF JSON or a Blender script. It writes one HTML file. The file loads Three.js from a CDN, builds the asset as a tree of Three.js objects, shows it in a preview with lights and orbit controls, and wires a button to Three.js's own `GLTFExporter`. Clicking the button serialises the asset subtree to a binary glTF in the browser and downloads it. The GLB is a by-product of running the code.

This is the system prompt, from the card, and it is the whole contract:

> Create game assets for the Godot editor, which uses GLB files. Create whatever the user requests as a good-quality low-poly asset using only Three.js in one complete standalone HTML file. The HTML must show the asset and include an Export GLB button that downloads a working GLB. If the requested asset needs animation, add it and include the animation in the exported GLB; otherwise make it static.

**Measured:** all 417 rows of the validation split carry that same 399-character string as their system message. Every one of the 417 answers is a fenced HTML block, and the export is always the same shape. Here it is from the first row, a merchant, trimmed:

```js
// Everything below this group is exported.
const exportRoot = new THREE.Group();
exportRoot.name = "LowPoly_Merchant";
scene.add(exportRoot);
// ...
exporter.parse(
  exportRoot,
  (result) => {
    const blob = new Blob([result], { type: "model/gltf-binary" });
    // ... object URL, <a download="low_poly_merchant.glb">, click
  },
  (error) => { /* "Export failed — see console" */ },
  { binary: true, onlyVisible: true /* , animations: [...] */ },
);
```

The pattern that matters is the split between the preview and the asset. In the merchant's page the lights, the ground and the backdrop live outside the exported group ("Preview environment — deliberately kept outside the export root"), so they do not end up in your game. Four GLBs in the split do carry a light, as `KHR_lights_punctual`. **Measured, over 417 pages:** none passes `scene` to the exporter. The first argument is a named group (`asset` in 178 pages, `assetRoot` in 84, `model` in 33, `exportRoot` in 15, and so on). 416 set `binary: true`, 413 set `onlyVisible: true`, and 228 set `trs: true`, which writes each node as translation, rotation and scale rather than a matrix.

The idiom also moved away from the first model's. That one imported only the Three.js core from one cdnjs URL and wrote its own camera controls. This one uses an import map pointing at jsDelivr, pulls three addons, and does not agree with itself on a version:

| Three.js version in the import map | Pages |
|---|---|
| 0.160.0 | 115 |
| 0.164.1 | 89 |
| 0.169.0 | 78 |
| 0.170.0 | 60 |
| 0.166.1 | 28 |
| 0.165.0, 0.160.1, 0.161.0, 0.167.1, 0.168.0 | 47 between them |

All 417 import `GLTFExporter`, 404 import `OrbitControls`, 100 import `RoundedBoxGeometry` and 22 `RoomEnvironment`. **Reasoned:** "only Three.js" here means Three.js plus its own addons, which the exporter makes unavoidable. Ten pinned versions are harmless for a page that runs once and is thrown away. They do mean that the generator string inside each GLB records which exporter wrote it, and that the model was trained on a mixture, so its own pages will pin a version from roughly that range.

## What was released

| Artefact | What it is | Licence |
|---|---|---|
| Safetensors | five shards, 17,490,033,664 bytes, FP32 language weights and BF16 vision tower | Apache-2.0 |
| GGUF | Q4_K_M (2,708,804,128 bytes) and Q8_0 (4,482,402,848 bytes), plus the base model's BF16 vision projector | Apache-2.0 |
| Dataset | 3,918 rows: 3,501 train, 417 validation; conversations, a preview render and the exported GLB for every row | MIT |
| Technical report | none this time | |

**Measured, from the safetensors headers by HTTP range request:** 4,539,265,536 parameters, 4,205,751,296 of them language weights in F32 and 333,514,240 vision weights in BF16. The 723 tensors are the same 723 as the first model's; the 15 `mtp.` tensors of the base [Qwen3.5-4B](https://huggingface.co/Qwen/Qwen3.5-4B) are still missing while `config.json` still declares `mtp_num_hidden_layers: 1`. The full-precision checkpoint is twice the size of the first model's FP16 one for no change in content: F32 is how it was saved, not a property of the weights. `generation_config.json` carries the card's sampling settings, temperature 0.8, top-k 40, top-p 0.95, min-p 0, no repetition penalty.

**Reported:** the card says the release is from 5 November and that anyone who downloaded the GGUFs before then should delete them and download again, because the filenames did not change. There is no training recipe, no loss curve and no evaluation table in the repo. What the card offers instead is a set of side-by-side images against DeepSeek V4.1 Flash 1017 on prompts "not included in its training dataset", a playable browser arena built from its assets with help from "an orchestration and validation harness", and one line of limitations: "Some requests need a retry or a small code correction." None of that is something I could check, and the card itself says a harness did part of the arena's work.

## The dataset

The dataset card describes 3,918 examples across 40 domains and 2,903 distinct subjects, and says all of them "were tested and rendered, and their exported GLB files were validated before inclusion". It ships three configurations that share ids and row order: `conversations` (prompt, thinking trace, answer), `previews` (one rendered image per row) and `meshes` (the GLB each row's own page exported).

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen35-4b-glb-3d-coder/dataset-grid.jpg?v=d76f7ed9f9721e10"
  alt="A grid of 32 small renders on pale backgrounds: a target sign, a hand truck, a tank, a coiled boiler, a turtle, a teddy bear, a tricycle, a telescope, a kettle, a workshop bench, a desktop computer, characters in tunics, treasure chests, a pink mushroom, a tracked vehicle and a yellow loader, all in a flat-shaded low-poly style."
  caption="32 of the 256 preview renders in the dataset card's grid, cropped from its top four rows. (ccore/ThreeJS-3D-GLB-Assets dataset card, preview grid)."
/>

Forty domains is generous. Several are the same category twice, once with about 120 to 220 rows and once with about 40: `companions_and_creatures` (139) and `creatures_and_companions` (43), `nature_and_environment` (119) and `nature_and_garden` (40), `enemies` (220) and `enemies_and_bosses` (38), `weapons_and_armor` (179) and `weapons_armor_and_equipment` (41). **Reasoned:** two generation batches with different labels, merged without folding the names together. It does not affect training; it does mean "40 domains" is closer to 30.

**Measured, on the validation split.** I pulled the 8.6 MB conversations shard and the 77 MB meshes shard and nothing else. Prompts come in two kinds. 275 are ten words or fewer ("Create a merchant.", "Create a mug.") and 142 are briefs of 54 to 78 words that name parts, materials, colours and motion. Titles are visibly templated: a subject recurs with a prefix, and "Magical", "Ancient", "Enchanted", "Weathered" and "Compact" lead dozens of them. No prompt is repeated. The thinking traces are terse and planning-shaped; the merchant's begins "We need create HTML standalone using Three.js. Need infer merchant likely low-poly character." The card does not say which model wrote the answers.

### How long an answer is

I counted every row with the model's own `tokenizer.json`, split into prompt, thinking and answer.

| Per row, 417 rows | Mean | Median | Longest |
|---|---|---|---|
| Prompt (system + user) | 113 | 90 | 177 |
| Thinking trace | 10,490 | 9,507 | 40,307 |
| Answer (the HTML) | 9,241 | 9,067 | 16,675 |
| Total | 19,843 | 19,222 | 52,909 |

The card says responses "average around 20K tokens", which these rows bear out, and that training examples reach 72K, which the training split may; the validation split's longest is 52,909. Against the first model's data this is a lighter task. Those rows averaged 29,001 tokens, 16,066 of thinking and 12,822 of code. One low-poly prop is a smaller program than one lit, fogged, animated scene.

Two patterns are worth knowing before you set a token limit. Long briefs make the model think more and write less: the median thinking trace is 12,038 tokens for a long brief against 8,246 for a short prompt, and the median answer 7,933 against 9,916. **Reasoned:** a short prompt leaves the design to the answer, and a long brief moves it into the plan. The other pattern is that the thinking comes first and is long, so a cap stops a generation in the worst place.

<OutputBudget />

The card warns that "an 8K output limit is too restrictive" and asks for unlimited output. The numbers are blunter than the warning. At 8,192 tokens, 268 of 417 answers would still be thinking when they were cut, which means no HTML at all, and 145 would stop partway through the page. Four finish. At 16,384, 119 finish. At 32,768, 396 do. **Reasoned:** set the limit to at least 32K, and to 64K if the brief is long, and treat these as the teacher's lengths, not a ceiling on the student's: the card publishes no length statistics for the model's own answers.

## The GLBs, through a validator

The `meshes` configuration is the part of this release I can test without running anyone's code, because a GLB is data. I extracted all 417 validation GLBs and ran each through the Khronos [glTF-Validator](https://github.com/KhronosGroup/glTF-Validator) (the npm build, version 2.0.0-dev.3.10) with no issue cap.

**Measured:**

- **417 of 417 have zero errors and zero warnings.** The dataset card's claim that its GLBs were validated holds for this split.
- The informational messages are nearly all one kind: 42,776 `UNUSED_OBJECT` notes in 412 files, every one I inspected a `TEXCOORD_0` attribute on a primitive whose material has no texture. `GLTFExporter` writes UVs whether or not anything reads them. That is dead weight, not a defect.
- 29 images have non-power-of-two sizes, one primitive has a degenerate triangle, and six files use `EXT_materials_bump`, an extension the validator does not know.
- The `asset.generator` string matches the page's pinned Three.js version in every file that records one (277 say `THREE.GLTFExporter r164` to `r170`; the 140 from r160 and r161 print no revision). **Reasoned:** the shipped GLBs were written by the pages they sit next to, as the card says, not converted from something else.

What is in them, by the validator's own counts:

| Per GLB, 417 files | p10 | Median | p90 | Max |
|---|---|---|---|---|
| Triangles | 1,570 | 4,124 | 14,392 | 69,662 |
| Vertices | 2,629 | 6,675 | 36,718 | 255,932 |
| Materials | 7 | 11 | 22 | 87 |
| Draw calls | 27 | 90 | 212 | 593 |
| File size | 137 KB | 373 KB | 1.39 MB | 7.0 MB |

The triangle counts are honestly low-poly: 349 of 417 assets are under 10,000 triangles. The vertex count runs above the triangle count because 314 of the pages set `flatShading: true` and many build geometry that does not share vertices across faces. Materials are `MeshStandardMaterial` in all 417 pages; 207 files use `KHR_materials_emissive_strength` for glowing parts and 60 use `KHR_materials_unlit`. 71 carry textures, and all 71 pages draw them on a canvas at runtime (`CanvasTexture`) rather than loading a file. 66 carry animation, and none carries a skin: when a character waves, a node rotates, no bone bends a mesh.

Animation is the part of the contract I can check against the prompt. **Measured:** 27 prompts ask for motion in words my keyword search catches ("animated", "opens", "rotating" and the like), and all 27 GLBs carry an animation. 122 prompts say "static" or "keep it static", and none of those GLBs does. Another 39 animate without a request my search found, mostly "magical" companions and creatures given an idle bob. Two pages build an `AnimationClip` that never reaches the file, both magical companions. That is a careful dataset.

## What a validator does not check

A glTF validator asks whether a file is a correct glTF. A game engine asks other questions: how big is this thing, where is its origin, and how many times will I have to draw it? Those are where I found the rough edges.

### Scale

The glTF 2.0 specification says the unit is the metre, and Godot imports one unit as one metre. **Measured,** from the scene bounds after applying every node transform:

<ScaleSpread />

A merchant is 3.29 m tall. The median of 65 characters and enemies is 3.37 m, and one of them stands between 1.6 and 1.9 m. Items and weapons have a median of 3.53 m, a treasure key is 7.82 m, a wood axe 3.91 m. The plain mug is 0.11 m, which is a mug; the magical mug is 2.21 m. The pocket watch is 0.12 m; the magical pocket watch is 5.74 m. There is no unit in the system prompt, and the pages do not compute one. Only 7 of 417 pages measure their own asset with a `Box3`. The camera is placed by hand, 8 to 13 units from the origin in 211 of the 307 pages whose camera position I could parse, and the asset is built to fill that view. The mug's page, modelled in real centimetres, puts its camera 0.33 units away.

<Figure
  src="https://media.thesatyajit.com/ai/articles/qwen35-4b-glb-3d-coder/scale-lineup.png?v=87423e11924d18c1"
  alt="Seven shipped assets drawn side by side at their true size in glTF units against a grid in metres, next to a grey 1.75 m bar for a person: a 0.11 m mug and a 0.12 m pocket watch barely visible at the base, a 3.29 m merchant, a 3.71 m zombie, a 2.10 m anvil, a 3.91 m wood axe whose handle runs below the ground line, and a 7.82 m treasure key that also extends below it."
  caption="Seven validation assets from the dataset's meshes, drawn at their exported size with my own flat-shaded projection of the GLB triangles and material colours; the grey bar is a 1.75 m person. My render, not the pages' preview."
/>

**Reasoned:** none of this is wrong as glTF, and every asset is one scale factor from correct. It is still a step each import needs, and it cannot be automated from the file alone, because the file does not know whether it is a mug or a monument. A level designer dropping a 3.3 m shopkeeper behind a 2.1 m anvil has to rescale both, by different factors.

### Origin

A prop should usually sit on its origin, so that placing it at a point on the floor puts it on the floor. **Measured:** 177 of 417 GLBs have their lowest point within 1 cm of y = 0, and 260 within 5 cm. 138 extend more than 5 cm below the origin, and 56 have more than 40% of their height below it, which is an asset centred on its origin rather than standing on it; the wood axe and the treasure key in the figure are two. Horizontally the data is tidier: 379 of 417 are centred on x and z to within a tenth of their size.

### Draw calls

Each `THREE.Mesh` in the page becomes a glTF node with its own mesh, and a Three.js page builds a merchant out of dozens of boxes, cylinders and tori. The median GLB has 102 nodes, 89 meshes and 11 materials, and the validator's draw-call count, one per primitive per node, has a median of 90. **Reasoned:** Godot keeps the node tree on import, so each mesh node becomes its own `MeshInstance3D`, and a scene with fifty such props starts at thousands of draw calls before anything else is drawn.

It is fixable offline, and I wanted to know by how much. My script, 72 lines of Python and NumPy over the GLB's own JSON and binary chunk, walks the node tree, bakes each node's transform into its vertices and normals (flipping the winding where a transform mirrors), and joins every primitive that shares a material into one. It only touches assets where that is safe: no animation, no skin, no texture, no lights. 278 of the 417 qualify; three more failed on a missing normal or a morph target and were left alone.

<DrawCallMerge />

**Measured:** all 278 merged files pass the same validator with zero errors and zero warnings, and their bounds agree with the originals' to within a millimetre in the 28 I spot-checked with trimesh. Draw calls across the 278 go from 27,950 to 3,436, a median of 87 down to 11. File size drops to 71.8% of the original in total, because one primitive per material carries far less accessor and node overhead than a hundred. The triangle count is identical in 213 of the 278. In the other 65 it is higher, and that is correct: the original reused one mesh in several nodes, and a baked merge writes every copy out.

The cost is editability. A merged asset is one node per material, and the part names the teacher gave each node (the merchant's run `Boot_Toe`, `Apron_Pocket`, `Hat_Band`, `Feather_Pin`) are gone. For an animated asset you cannot do it at all without keeping the moving parts separate. So the decision is per asset: merge static props and scenery, keep characters and anything that opens.

## What I could not run

- **The pages.** The plan was to open sampled validation pages in headless Chromium, click Export GLB, and check that the code in the dataset reproduces the GLB the dataset ships. The sandbox's permission layer refused to run the dataset's HTML in a browser, and I did not try to work around it. What I have instead is the generator-string match above, which shows the exporter versions line up, not that the bytes do.
- **The model.** The full-precision weights are 17.49 GB and an answer averages about 20,000 tokens, most of it thinking. That is hours a generation on the shared CPU I work on, and the memory to hold the model would have exceeded what I am allowed. Every statement about the student's own output is the card's, and the card publishes no numbers for it.
- **The training split.** 417 rows of 3,918 are what I measured. The validation split is drawn from the same domains in the same proportions, by the dataset's own stats file, but the 72K-token example the card mentions is not in it.
- **Godot.** I reasoned about Godot's importer from how it maps glTF nodes; I did not import anything into the editor.
- **Visual quality.** The validator and my bounds check say nothing about whether a goblin looks like a goblin. The dataset's preview renders and the card's comparisons are the only evidence for that, and they are the author's selection.

## What to do with it

If you want assets out of this model:

1. Run the Q8_0 GGUF (the card's evaluated quantisation) with the system prompt verbatim and the card's sampling settings. Give it an output limit of at least 32K tokens; 8K finishes almost nothing.
2. Save the HTML, open it in a browser with network access, and click Export GLB. The page fetches Three.js and three addons from jsDelivr, so it will not work offline as written.
3. On import, fix scale first. Decide the real height of the thing and scale the root to it; nothing in the file can tell you.
4. Check the origin. If the asset is centred rather than grounded, move the root so its lowest point is at zero.
5. For static props, merge by material, in Godot or in a preprocessing script like mine; median draw calls drop from 87 to 11 in this data. Keep animated assets as they are.

If you want to train on the data: it is unusually clean. Every shipped GLB I checked is valid, every validation prompt is unique, the animation contract holds, and it comes with a rendered reference per row. Fold the duplicate domain labels together first, and consider adding a scale to the prompt, because the model has learnt the teacher's camera, not the world's.

Related reading: the [first CCORE model](/articles/qwen35-4b-3d-coder), whose 213 teacher scenes I ran in a browser; [Construct 1.0](/articles/construct-3d-objects), where an agent writes a JSON scene and a deterministic compiler writes the glTF with pivots and an opening animation, the opposite design choice to letting the model write the exporter call; and [3DCodeVerse](/articles/3dcodeverse), a much larger corpus of executable 3D programs.
