Free CC0 3D models and textures for three.js: find one, read its licence, place it at real size

Where to get a free 3D model or tiling PBR texture that a three.js site may use, what CC0 and CC BY 4.0 ask of you, and how a coding agent finds one with the paprika3d plugin's asset_search tool (by words or by a crop of a photo), picks it into the project with its loading code and a credit line, and checks it on the page with look.

What "free to use" means for a model or a texture

three.js loads glTF (.gltf or .glb) with GLTFLoader, and a texture is an image file that TextureLoader reads, so the format question has a short answer: look for GLB models and for PNG, JPEG or WebP texture maps. The licence question takes more care. The two licences most free assets use:

Other licences on model sites (CC BY-NC, which forbids commercial use; CC BY-SA, which puts your changes under the same licence; a site's own "free" licence) each need reading before use. A free model is also not automatically fit for the web: scans and sculpts can be hundreds of thousands of triangles, textures can be far larger than the 2048 px a web page needs, and few models arrive at their real size in metres.

paprika3d is a Claude Code plugin and MCP server that opens a three.js page in its own headless Chrome. Its asset_search tool searches a hosted library of models, PBR textures and HDRIs for one that fits, by words or by a crop of a picture, and pick puts the chosen file in the project with the code that loads it: a model at real size, or at the height asked, with a credit line written to CREDITS.md. model_audit reads the picked files, and look shows them on the page with their size.

Install

paprika3d needs Google Chrome and Node 20.11 or later, and the Claude Code plugin needs Claude Code 2.1.118 or later.

claude plugin marketplace add paprika3d/paprika3d
claude plugin install paprika3d@paprika3d
npm install three

What the library is

The library behind asset_search is hosted by paprika3d at assets.keeno.dev. As of 2026-10-28 it holds 57,732 models under CC BY 4.0 or CC0: 57,183 from Objaverse, a dataset of models published on Sketchfab with the licence each author chose there, and 549 AI-generated ones from Scenario's 3D Collection (CC BY 4.0, credited to Scenario). It also holds 3,545 CC0 PBR textures from Poly Haven, ambientCG, TextureCan and 3DTextures.me, and 1,430 CC0 HDRIs. Each model was rewritten for the web before it went in: meshopt-compressed geometry, WebP textures no larger than 2048 px, and its origin at the centre of its base. Branded and character assets are filtered out before publishing, and sources whose terms do not allow re-hosting are left out.

The library learns from use: it logs model searches, picks and rejections with the agent's name (texture and HDRI searches are not logged), and it sees the caller as a keyed hash of the IP address, not the address.

The tool calls, in order

The task: a reading corner for a furniture page, with a rocking chair like the one in a photo, a wooden floor and a planter. The calls were made on 2026-10-28 on a Linux server with no GPU, so Chrome drew in software (SwiftShader). Run folder paths are shortened to <project>, and replies are cut to the lines this guide discusses, with nothing reworded. Each search reply also carries a contact sheet, one picture with the hits' thumbnails numbered as the lines are; it is left out here.

1. Search by words.

asset_search {"cwd": "<project>", "query": "rocking chair"}

1. d66764db18324c718cfe4a3bca40433d · Lounge Chair · scanned · 187494 tris · 1.492 MB · about 1 m tall · CC-BY-4.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/thumb/d66764db18324c718cfe4a3bca40433d-r2.webp
2. a55f03e5ab214921a99af0f2120ea519 · Rocking chair · realistic · 2586 tris · 0.287 MB · about 1 m tall · CC-BY-4.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/thumb/a55f03e5ab214921a99af0f2120ea519-r2.webp
3. aebc93f3a0c443bd94a08f934323982d · Rocking Chair · stylized · 12789 tris · 0.235 MB · about 1 m tall · CC-BY-4.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/thumb/aebc93f3a0c443bd94a08f934323982d.webp
Pick one with { pick: "<id>" } (real size, or height: units tall), search again with not: [ids] to reject these, or add more: true for the next ones.

Each line has what decides the choice before any download: the style, the triangle count, the file size, the real height and the licence. The first hit is a 187,494-triangle scan of a different chair. more asks for the next hits of the same query, past the ones shown, without rejecting any:

asset_search {"cwd": "<project>", "query": "rocking chair", "more": true, "sheet": false}

1. 10de91400b454d0fb04e23f699c95cf3 · Rocking Chair · stylized · 420 tris · 0.008 MB · about 1 m tall · CC-BY-4.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/thumb/10de91400b454d0fb04e23f699c95cf3-r2.webp
2. bcd3f4e05ec44902af3b5668b2b831ca · Rocking Chair · low-poly · 1610 tris · 0.174 MB · about 1 m tall · CC-BY-4.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/thumb/bcd3f4e05ec44902af3b5668b2b831ca.webp
3. 0102b2c1449f448687d62ea66ae2a26a · Chair · realistic · 2256 tris · 0.096 MB · about 0.9 m tall · CC-BY-4.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/thumb/0102b2c1449f448687d62ea66ae2a26a-r2.webp

not with a list of ids is the other way on: it rejects those hits, which the library learns from, and searches again without them.

2. Search by a crop of a photo. The project has refs/attic.jpg, a CC0 photograph from Wikimedia Commons ("Black Rocking Chair (Unsplash)", by Anthony Delanoix) of a spindle-back rocking chair in an attic, 1280 px wide. image with crop cuts the chair out of it, in the picture's pixels, and ranks models by how alike they look, with the words as well:

asset_search {"cwd": "<project>", "query": "rocking chair", "image": "refs/attic.jpg", "crop": [390, 355, 680, 670]}

1. aebc93f3a0c443bd94a08f934323982d · Rocking Chair · stylized · 12789 tris · 0.235 MB · about 1 m tall · CC-BY-4.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/thumb/aebc93f3a0c443bd94a08f934323982d.webp
2. 5c5d1c3bb4494ed9bcad956b6b4709d5 · Rocking Chair - Low Poly · low-poly · 5758 tris · 0.273 MB · about 1 m tall · CC-BY-4.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/thumb/5c5d1c3bb4494ed9bcad956b6b4709d5.webp
3. 922babc233ae4da9bf2835427644002f · Rocking Chair · realistic · 3942 tris · 0.283 MB · about 1 m tall · CC-BY-4.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/thumb/922babc233ae4da9bf2835427644002f-r2.webp
4. d5e784aa92464572af37264c3b3d2fa6 · Western Rocking Chair · realistic · 2497 tris · 0.224 MB · about 1.05 m tall · CC-BY-4.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/thumb/d5e784aa92464572af37264c3b3d2fa6-r2.webp
5. 400b2f945fb24beaa5e3ce576670144e · Weathered Rocking Chair · scanned · 171796 tris · 1.22 MB · about 1.2 m tall · CC-BY-4.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/thumb/400b2f945fb24beaa5e3ce576670144e-r2.webp

Every hit is now a rocking chair, and the one ranked third by words alone is first: its thumbnail shows the same tall spindle back and curved top rail as the photo.

3. Pick it. An id's first characters are enough when no other hit shown in the project starts with them:

asset_search {"cwd": "<project>", "pick": "aebc93f3"}

Picked aebc93f3a0c443bd94a08f934323982d, the one shown id that starts with "aebc93f3".
Saved assets/rocking-chair-aebc93.glb (Rocking Chair, CC-BY-4.0); credited in assets/CREDITS.md
If the import map has no `three/addons/`, map it to three's `examples/jsm/` folder.

The reply goes on with the code that loads it:

import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "three/addons/libs/meshopt_decoder.module.js";

const rockingChair = new THREE.Group();
{ // a block, so a second pick's snippet pastes beside this one
  const loader = new GLTFLoader();
  loader.setMeshoptDecoder(MeshoptDecoder);
  const gltf = await loader.loadAsync("assets/rocking-chair-aebc93.glb");
  const box = new THREE.Box3().setFromObject(gltf.scene);
  gltf.scene.position.set(-(box.min.x + box.max.x) / 2, -box.min.y, -(box.min.z + box.max.z) / 2); // origin to floor centre
  rockingChair.add(gltf.scene);
}
rockingChair.scale.setScalar(0.733229); // real size, about 1 m tall
scene.add(rockingChair);

The code sets up the meshopt decoder the library's files need (the Draco, meshopt and KTX2 guide explains why), puts the model's origin at the centre of its base, and scales it to real size.

4. A tiling floor texture. kind: "texture" searches the PBR textures; each line names its maps and, when the source says, how many metres one tile covers:

asset_search {"cwd": "<project>", "query": "oak wood floor planks", "kind": "texture", "sheet": false}

1. polyhaven-oak_wood_planks · Oak Wood Planks · maps: basecolor, normal, roughness, ao, displacement · tiles every 1.2 × 1.2 m · CC0-1.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/tex/polyhaven-oak_wood_planks/preview.webp
2. ambientcg-Planks033B · Planks 033 B · maps: ao, basecolor, displacement, metalness, normal, roughness · CC0-1.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/tex/ambientcg-Planks033B/preview.webp

asset_search {"cwd": "<project>", "kind": "texture", "pick": "polyhaven-oak_wood_planks"}

Saved assets/textures/polyhaven-oak_wood_planks/: basecolor, normal, roughness, ao (Oak Wood Planks, CC0-1.0)

The reply goes on with the code that loads it:

const oakWoodPlanks = (() => {
  const loadMap = (file, srgb) => {
    const t = new THREE.TextureLoader().load("assets/textures/polyhaven-oak_wood_planks/" + file);
    t.wrapS = t.wrapT = THREE.RepeatWrapping;
    t.repeat.set(10 / 1.2, 10 / 1.2); // a 10 × 10 m surface with 0-1 UVs; with UVs in metres, 1 / 1.2
    if (srgb) t.colorSpace = THREE.SRGBColorSpace; // colour map only
    return t;
  };
  return new THREE.MeshStandardMaterial({
    map: loadMap("basecolor.webp", true),
    normalMap: loadMap("normal.webp"),
    roughnessMap: loadMap("roughness.webp"),
    aoMap: loadMap("ao.webp"),
  });
})();

The pick saves the 1K version of each map the material uses and leaves out the displacement map, which moves vertices and needs a dense mesh. The colour map alone is tagged sRGB. A texture pick writes no credit line: the textures are CC0.

5. A model at the height asked. height scales a model to that many units tall instead of its real size, here a planter 0.45 m tall where the library's pot is about 0.25 m:

asset_search {"cwd": "<project>", "query": "terracotta flower pot", "sheet": false}

1. 90762992c41d49569c098517a49dcb8a · Pot On Platform · realistic · 22344 tris · 0.355 MB · about 0.4 m tall · CC-BY-4.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/thumb/90762992c41d49569c098517a49dcb8a-r2.webp
2. b99c584dd11d4691b5d13303383371e5 · FlowerPot · realistic · 4604 tris · 0.126 MB · about 0.2 m tall · CC-BY-4.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/thumb/b99c584dd11d4691b5d13303383371e5.webp
3. 305f2b09e0de48bb919116026aba8f44 · flowerpot · realistic · 5324 tris · 0.054 MB · about 0.25 m tall · CC-BY-4.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/thumb/305f2b09e0de48bb919116026aba8f44-r2.webp

asset_search {"cwd": "<project>", "pick": "305f2b09", "height": 0.45}

Picked 305f2b09e0de48bb919116026aba8f44, the one shown id that starts with "305f2b09".
Saved assets/flowerpot-305f2b.glb (flowerpot, CC-BY-4.0); credited in assets/CREDITS.md
flowerpot.scale.setScalar(0.45 / 1.233); // 0.45 units tall

6. Read the credits and the files.

$ cat assets/CREDITS.md
- Rocking Chair: "Rocking Chair" by Lightningx (https://sketchfab.com/3d-models/aebc93f3a0c443bd94a08f934323982d) is licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Optimized for the web by paprika3D.
- flowerpot: "flowerpot" by laura-han (https://sketchfab.com/3d-models/305f2b09e0de48bb919116026aba8f44) is licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Optimized for the web by paprika3D.

Each line names the work, its author, the source page, the licence with its link, and the change made, which is what CC BY 4.0 asks for. A model is credited once, however often it is picked. model_audit reads the two files as the page will get them:

model_audit {"cwd": "<project>", "path": "assets"}

assets/rocking-chair-aebc93.glb · 230 KB · 12,789 triangles · 1 material · textures: 1024×1024 webp, 1024×1024 webp · Draco no, meshopt yes, KTX2 no
site: passes

assets/flowerpot-305f2b.glb · 53 KB · 5,324 triangles · 1 material · textures: 512×256 webp · Draco no, meshopt yes, KTX2 no
site: passes

7. See them on the page. The three snippets went into index.html as they came, with the two models' shared imports written once, a 10 m plane for the floor, and the planter moved beside the chair. open, look and an eval that measures each picked model:

open {"url": "index.html", "cwd": "<project>"}

Opened http://127.0.0.1:41157/index.html in 0.3 s, paused at frame 0.
{"three":"186","scenes":1,"entities":["floor","rockingChair","flowerpot"]}
Loaded: rocking-chair-aebc93.glb 0.2 MB, flowerpot-305f2b.glb 0.1 MB

look {}

On screen (3):
floor 62% [0, 218, 1279, 799] 10 × 10 units
pCylinder1_lambert6_0 3.3% [821, 378, 1023, 588]
PilierHautAvantDroit2_RockingChair_Tex_0 3.1% [433, 195, 694, 564]
Never visible: none

eval {"code": "const size = (o) => new THREE.Box3().setFromObject(o).getSize(new THREE.Vector3()).toArray().map((v) => +v.toFixed(2));\nreturn { rockingChair: size(find('rockingChair')), flowerpot: size(find('flowerpot')) }"}

{"rockingChair":[0.45,1,0.82],"flowerpot":[0.52,0.45,0.53]}

The chair stands 1 unit (a metre) tall and the planter 0.45, as asked. look lists the meshes by the names their authors gave them inside the files, which is worth knowing before writing code that finds a part by name. The same look also reported a metal part with no environment map (the too-dark model guide covers that line) and the far edge of the floor in view; neither is about the assets.

Placing a model from anywhere else

A model downloaded from another site needs the same two steps the pick's code takes: origin to the centre of its base, then a scale from its own units to metres. A texture needs its repeat set from the metres one tile covers. This page does both, with stand-ins for the downloaded files, so it runs on its own:

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Placing a downloaded model</title>
<style>html,body{margin:0;overflow:hidden}canvas{display:block}</style></head>
<body>
<script type="importmap">{"imports":{"three":"./node_modules/three/build/three.module.js","three/addons/":"./node_modules/three/examples/jsm/"}}</script>
<script type="module">
import * as THREE from 'three';

// A model from any source: put its origin at the centre of its base, then scale it to a height in metres.
// (What the code from an asset_search pick does; a GLB from elsewhere needs the same two steps.)
function placeAtHeight(model, metres) {
  const holder = new THREE.Group();
  const box = new THREE.Box3().setFromObject(model);
  model.position.set(-(box.min.x + box.max.x) / 2, -box.min.y, -(box.min.z + box.max.z) / 2);
  holder.add(model);
  holder.scale.setScalar(metres / (box.max.y - box.min.y));
  return holder;
}

// A seamless texture that covers `tileMetres` per repeat, on a plane `sizeMetres` wide with 0 to 1 UVs.
function tileEvery(texture, tileMetres, sizeMetres) {
  texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
  texture.repeat.set(sizeMetres / tileMetres, sizeMetres / tileMetres);
  return texture;
}

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xd9d4cc);
scene.fog = new THREE.Fog(0xd9d4cc, 4, 9);
const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 50);
camera.position.set(1.6, 1.3, 2.4);
camera.lookAt(0.2, 0.45, 0);
scene.add(new THREE.HemisphereLight(0xfff4e6, 0x6b5a48, 2));
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(2, 4, 3);
scene.add(sun);

// Stand-in for a downloaded plank texture: one 1.2 m tile drawn on a canvas.
const canvas = Object.assign(document.createElement('canvas'), { width: 256, height: 256 });
const g = canvas.getContext('2d');
for (let i = 0; i < 8; i++) { g.fillStyle = i % 2 ? '#b07a48' : '#c08a55'; g.fillRect(0, i * 32, 256, 32); }
const planks = new THREE.CanvasTexture(canvas);
planks.colorSpace = THREE.SRGBColorSpace;
const floor = new THREE.Mesh(new THREE.PlaneGeometry(10, 10), new THREE.MeshStandardMaterial({ map: tileEvery(planks, 1.2, 10) }));
floor.name = 'floor';
floor.rotation.x = -Math.PI / 2;
scene.add(floor);

// Stand-in for a downloaded model in its own units (here 2.3 units tall, offset from its origin).
const stool = new THREE.Group();
const wood = new THREE.MeshStandardMaterial({ color: 0x6b4a2e, roughness: 0.7 });
const seat = new THREE.Mesh(new THREE.CylinderGeometry(0.9, 0.9, 0.2, 32), wood);
seat.position.set(5, 2.2, 3);
stool.add(seat);
for (const [x, z] of [[-0.5, -0.5], [0.5, -0.5], [-0.5, 0.5], [0.5, 0.5]]) {
  const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.1, 2.1, 12), wood);
  leg.position.set(5 + x, 1.05, 3 + z);
  stool.add(leg);
}
const placed = placeAtHeight(stool, 0.65);  // a stool 65 cm tall
placed.name = 'stool';
scene.add(placed);
console.log('stool size in metres:', new THREE.Box3().setFromObject(placed).getSize(new THREE.Vector3()).toArray().map((v) => +v.toFixed(2)));

addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});
renderer.setAnimationLoop(() => renderer.render(scene, camera));
</script>
</body></html>

Measured with eval on the opened page, its size is [0.51,0.65,0.51]: the stand-in, 2.3 units tall in its own units and offset from its origin, now stands 0.65 m tall on the floor, centred at the origin.

What paprika3d does not do

Pitfalls

Without paprika3d

Free CC0 textures and HDRIs: Poly Haven and ambientCG publish PBR texture sets with every map, at several resolutions, and Poly Haven also has CC0 models and HDRIs. Free models: Kenney and Quaternius publish low-poly game asset packs as glTF (read each pack's current licence page); Sketchfab's search has a "Downloadable" filter and a licence filter, and offers many models as glTF; the Smithsonian's 3D collection (3d.si.edu) publishes scans of museum objects, many of them CC0. Whichever the source, check the licence on the model's own page, keep the author, link and licence in a credits file, and put the model at real size with a Box3 as in the page above. To make a heavy download lighter, the GLB file size guide shows the steps.