Rendering kicad models with VRMLLoader: functionality and performance

Hello threejs community! First off, thank you for all your hard work on three.js; I’ve been really impressed at how approachable I’ve found the whole ecosystem and its many examples. What a joy it is to be able to get such quick and impressive results with the tool!

Some quick background on what I’ve done to this point: I have a PCB design in kicad, and there’s a built-in 3d render that looks something like this:

I’d like to be able to use this 3d render in contexts outside of the built-in 3d viewer. Happily, there’s an “export to VRML” (and only to VRML), and with a few minor tweaks[1] I was able to adapt the three.js webgl_loader_vrml.html example to load and render the board! The trickiest part was figuring out that kicad is scaling the model to be a realistic few dozen mm in size, so three.js’s meter-long defaults were about a thousand times too big.

So at this point I’ve gotten a working model[2] render, and that’s very exciting! Like I said above, I was kind of amazed I could get this much up and running with relatively little effort.

There are a couple problems, though:

  1. The loading itself takes quite a while; I haven’t measured precisely, but from page load to the last “pop in” is on the order of 10 seconds in my testing.
  2. Once loaded, the frame rate is… not great. As shown above, I’m seeing ~24fps, and the orbital controls feel very sluggish.

The “weightiest” object in both cases is the RJ-45 jack on the right side of the screengrab there: it takes ~3-4 CPU-bound seconds to load in my setup, and toggling its visible flag[3] more than halves the object/draw count from ~7000 down to ~3000.

Before I get too far into more of the details, I want to pause here and ask which, if any, of those numbers stand out to you. Like, 24fps is a little lower than seems to be the common target, but is a parser throughput on the order of 1MB/s considered fairly typical? When you hear “a few thousand objects/calls,” does that seem like it’d be beyond a common optimization target? I ask because from my perspective this isn’t a particularly large or complicated PCB, and a ten- or hundred-fold increase in complexity seems quite possible to me.

I’ve done a little exploring already with adding post-processing passes to cut down the object count (spoiler: it’s fairly straightforward to merge the geometries from the ~3000 object RJ-45 jack down to 7). With a bit of fiddling, that’s gotten me down to ~1500 objects/calls, which yields halfway decent frame rates for this PCB (north of 60fps, anyway). That said, it doesn’t look like it changes the shape of the profile very much to merge those objects, just shrinks the absolute timings. That suggests to me that ramping the complexity of the design is going to put me right back in the possibly-single-digit performance range.

So I suppose what I’m looking for at this point is whether or not I’m “barking up the wrong tree”: do I have a problem that’s outside the scope of what three.js is intended for? Or, have I done something to misuse the tool?


  1. Namely, fixing parsing for numbers that end with a . (like 10.) and adding support for Inline nodes. The full diff for that is here: three.js VRMLLoader with fixes for kicad models · GitHub ↩︎

  2. Link to a branch containing this code: router/SYZYGY-PHY at wip/threejs-renderer · rustbox/router · GitHub ; the “first working render” is commit ↩︎

  3. by way of the shapes3D/ARJM... toggle in the Models menu, if you’re playing along at home. ↩︎

Yup. I’ve worked on commercial PCB rendering software. You’re on the right track with merging. If you need a bit more flexibility/complexity you can check out three.InstancedMesh, but merging geometries will get you pretty far.

1 Like

To my knowledge, the target frame rate of Three.js is always 60 fps. Getting higher numbers seems odd at this point. Lower numbers are obviously sub-par.

A number of draw calls of ~7’000 seems outrageous to me. Getting this number down would be of my prime concern.

I’m not sure if what you are referring to a “object/draw count” is the same as what renderer.info reports.

For comparison: my recent animation of a vacuum engine clocks at ~100 draw calls and ~240’000 triangles and maintains 60 fps on dated hardware (iMac of 2014, MacBook Air of 2017).

3 Likes

60 — 144hz+ displays are pretty common nowadays, so I wouldn’t necessarily count on 60 being the top end.

1 Like

I was a little amazed when I learnt that requestAnimationFrame() was not a Three.js, but a browser function:

The frequency of calls to the callback function will generally match the display refresh rate. The most common refresh rate is 60hz, (60 cycles/frames per second), though 75hz, 120hz, and 144hz are also widely used.
Window: requestAnimationFrame() method - Web APIs | MDN

2 Likes

This could/should be one draw call.

You can convert the vrml into a generic json by writing a node program and using the vrml loader.

This could/should be one draw call.

Multiple materials/material types/textures. Probably pretty hard to do 1 drawcall… :smiley:

I don’t see why this would be multiple material types?

Each component on a PCB is a separate model file… with different textures for details (resistor colors etc), and different metalness/roughness for the components. (chrome, vs pcb plastic, vs gold traces etc)

To do one drawcall you’d have to merge and bake everything.

My main dev rig is a 60hz display… I have a 144hz higher end rig that is starting to ruin 60fps experiences for me. When I switch to it, it feels more real than reality… and when I switch back to 60, it takes a while for it to feel normal again. On the plus side its made me more aware of framerate dependency in some of my apps. (though this isn’t always easy to address!)

1 Like

Back to your initial question(s):

I see basically three possible approaches to your performance problems, not counting any combinations thereof.

  • pre-export - on the KiCad side:
    evaluate, via test export, the “cost” in terms of draw calls and triangle count of your basic primitives, that is: SMD-resistors, -capacitors, through-contacts, copper-strips etc… If applicable and possible, use simplified primitive geometries as a replacement.

  • “layered” exports:
    if possible, separate equal components into a distinct layer and export only this one layer… If KiCad doesn’t support this, make multiple copies from you KiCad project file and delete all unwanted objects from a copy so you wind up with a copy having only capacitors in it. Proceed for all different types of primitives/parts. This would make it easier for you to merge similar geometries into one, reducing the amount of draw calls.

  • post-export, on the Three.js side:
    “traverse” the resulting geometry and try to collect similar objects into one geometry, again via merging them. You may implement Instanced geometries if they are really similar (except for scale, rotation, position) and have the same material properties.

Depending on how you intend to use the visualisation, you may consider LODs for certain objects. Note however, that merged geometries and the use of LODs may counteract each other, especially in the context of frustum culling, but also in LOD level-switching.

2 Likes

I could suggest that you also try loading your model in my VRML Viewer which currently includes your patch.

If things work fine then you will have multiple export options available, not necessarily perfect but usually functional.

Maybe try using GLB_d and / or GLB_m export options, which might optimize and compress your model and allow you to switch to GLTF workflow.

Wow, thanks all for your thoughtful guidance! It sounds like I haven’t made any obvious mistakes, which is great news. It also sounds like I’m more or less on the right track, although getting down to one (1) or even 100 “things” does seem a little bit out of reach. That said, all I’m really looking for is a way to generate an image file at a particular resolution, so probably even 24fps is “plenty” for that case; even so, the rasterizing time will be dominated by the 10-ish second load time. Thanks for helping frame up the context, though: definitely cranking up to 144Hz+ would drastically limit the realtime budget!

Regarding model complexity: I’m sure it’d be quite possible to hand-model this in a way that it would be much cheaper to draw, but the challenge (as @manthrax notes) is that I’ve got a lot of little models that come from each individual vendor all being composited into one “thing.” The ~7000 number[1] is what happens when that collection of models is more or less directly reproduced, as with the current VRML loader. For example, this chip:

screenshot of the underside of a chip

Is more than 50 objects, as each of the edge pads are individually modeled; one of the sub-parts is something like:

DEF SHAPE_14 Shape {
 appearance USE APP_2
 geometry DEF FACE_14 IndexedFaceSet {
  coord DEF COORD_14 Coordinate { point [
  0 0 0.039370079,0 2.0472441 0.039370079,
  2.0472441 0 0.039370079,2.0472441 2.0472441 0.039370079] }
 coordIndex [
  3,1,0,-1,3,0,2,-1]
  normalPerVertex TRUE
  normal DEF NORM_14 Normal { vector [
  0 0 1,0 0 1,
  0 0 1,0 0 1] }
}
}

—-shapes3D/GPY111.wrl

The current VRML loader is defensively cloning the material referenced by the appearance USE APP_2. The two main batching tools I see are:

  1. BufferGeometryUtils.mergeGeometries allows taking the multiple definitions of each pad there and “joining” them along the shared USE APP_2 reference. That’s the strategy I implemented above, when I mentioned that I’d gotten down to ~1000 objects. Should the VRML loader ought to use BufferGeometryUtils.mergeGeometries by default? Is there any downside to merging these objects that definitionally share a material?
  2. InstancedMesh binds a single “material” & “geometry” pair together across many linear transformations; crucially, we have to identify not just “material” matches, but matching “geometries” as well, right?

If I were hand-drawing these PCBs, I think I understand how #2 would be a very helpful strategy: each SMD resistor/capacitor is approximately a simple box with a few coloring differences on some of the faces. Having all of those batched into a single instanced mesh, once per part, would offer a significant optimization over the ~10+ “objects” count for each.

What I’m not seeing an easy solution for, at least at present, is “recognizing” that two objects are linear transformations of each other; e.g. is SHAPE_14 able to be combined in an InstancedMesh with SHAPE_15? I’m not really asking about whether it’s possible to build a model where the pads are each instances, but whether it’s possible to take the soup of existing models and identify these commonalities within them. I suspect there is no simple answer, but perhaps this is a common enough problem that one of y’all has experience with it?

Finally, if “merging” means sharing one “material” across many batched geometries, and “instancing” means sharing one material & geometry across many batched transformations, is there anything in Three.js-land for higher-order batching of many materials across many geometries? I note that, once loaded, my object model is both a fixed size and arrangement, and the only thing that’s changing is the camera. I’m imagining shipping the model data to the GPU once, especially as it’s only a few hundred kilobytes in total, and then only sending updates to the projection matrix when that changes. “Hey go apply this 4x4 transformation matrix 7,000 times” feels like the kind of problem that even a very low-powered GPU would have little trouble with.


  1. “objects” are from ~ let count = 0; scene.traverseVisible(_ => count++). The “calls” are indeed from renderer.info.render.calls. I assume the difference between them has mostly to do with grouping/transform nodes in the scene graph? ↩︎

Oh, thanks for applying my patch! It looks like I need to do a little more work on it, though. When I pointed the viewer to the URL: https://raw.githubusercontent.com/rustbox/router/f280f251928c05cfcf7f3de7bd70b8daf25dd91b/SYZYGY-PHY/SYZYGY-PHY.wrl, it tried to load the “Inline” objects from e.g. https://githubdragonfly.github.io/viewers/templates/shapes3D/GPY111.wrl instead of from a path relative to the original .wrl file.

merging actually implies combining multiple geometries into one geometry.

What you are describing is de-duping materials, which is also very helpful… but actually merging geometries is even more effective.
For instance, the chip image you posted… that can be trivially reduced to 2 geometries and 2 materials.

Additionally… the dark part of the chip can be merged with any other chips using the same dark material.

So… the order of operations for optimizing something like this is…

  1. load the whole unoptimized model.
  2. traverse the loaded “scene” and collect all meshes
  3. loop through the meshes… and create a key based on the material params and putting them in a map of arrays
  4. Loop through the arrays and reassign the mesh.material in each array to the same single material instance.
  5. now for each array of materials… use BufferGeometryUtils.mergeGeometries( to merge the geometries into one Mesh… you will likely first have to transform each geometry to world space by using geometry.apply(mesh.matrixWorld), before doing the merge…

after all this… you should have only unique materials and a single mesh per material.
My rough estimate looking at the board image you linked, is that there are ~10 or so unique materials… thus you may end up with 10 meshes.

If you drag the VRML from that board into this thread, I can probably walk you through the process if you get stuck.

This is the part that I’m getting a little bit stuck on—the appearance names there are only unique per file (component), and otherwise I’m looking at something like this:

 material Material {
   ambientIntensity 0.33333331
   diffuseColor 0.30000001 0.30000001 0.30000001
   emissiveColor 0 0 0
   shininess 0.1
   specularColor 0.12 0.12 0.12
   transparency 0
}

I guess what you’re suggesting is that I find Materials that are “close enough”… by, like, enumerating all the defined properties I can find, and comparing them with some epsilon? So something that has diffuseColor 0.30000002 0.30000002 0.30000002 (or whatever the next valid floating point number is) would “match” and I’d merge the geometries from the mesh associated with either THREE.Material?

It’s all on github—I’ve apparently been flagged as a spammer(?), so I can’t send you a link at the moment; I’d be happy to share them via another mechanism if there’s a more preferred way (does the forum allow ~10MB attachments?).

I’d just go for exact match… and then worry about epsilon later if you’re not getting as many matches as you’d like. There should be enough duplication across the identical components to get some good matches.
all the resistors/caps etc. the traces will all have the same material… etc.

r.e. getting flagged for spam… probably just some automated trigger since there were urls in >1 of your posts.

I grabbed your .wrl file from the url you linked dragonfly… i’ll take a look…

edit: that wrl file generates errors when loading via VRMLLoader so maybe that’s not the right one.
You can throw it in a google drive or something and link it here.

Hmm, my misunderstanding might just be more mechanical, then—when you say “exact match” I’m imagining a big ol chained if like:

function exactMatch(a, b) {
    if (a.blendAlpha !== b.blendAlpha) return false;
    if (a.blendColor !== b.blendColor) return false;
   // ...
}

for all of the properties of a Material. Is that what you mean?

key = JSON.stringify([
a.blendAlpha,
a.color,
a. …
])

I did bother making some changes to the VRML Loader on my end and was actually able to load your SYZYGY-PHY.wrl model and also convert it to GLB format with MESHOPT compression.

It is attached below and you should try loading it into gltf.report to see the GPU memory and number of draw calls.

I still need to double check my code changes before uploading to my repository.

SYZYGY-PHY_GLB_MESHOPT.zip (787.9 KB)

2 Likes