ZOZO's Contact Solver ๐ซถ
A contact solver for physics-based simulations involving ๐ shells, ๐ชต solids, ๐ชข rods, ๐งฑ rigid bodies and โณ sand. Started as an in-house physics engine at ZOZO, Inc., the largest fashion e-commerce company in Japan, and now evolving with the community. All open-source.
๐ค LLM Transparency: We highly respect that readers expect to hear the author's original voice and tone, which we work to retain throughout. Our use of LLMs is clarified in (Markdown).
๐ Quick Look
๐จ Simulate remotely from our Blender add-on (screenshots taken on macOS; you can also run locally if you have a modern NVIDIA GPU on Windows or Linux)
blender-addon-trailer-2026.mp4
๐ Or double click start.bat (Windows) or run a Docker command (Linux/Windows) to get it running
๐ Click the URL and explore our examples
โจ Highlights
- ๐ช Robust: Contact resolutions are penetration-free. No snagging intersections.
- โฒ Scalable: An extreme case includes beyond 180M contacts. Not just one million.
- ๐ฒ Cache Efficient: All on the GPU runs in single precision. No double precision.
- ๐ฅผ Not Rubbery: Triangles never extend beyond strict upper bounds (e.g., 1%).
- ๐ Finite Element Method: We use FEM for deformables and symbolic force jacobians.
- ๐ Parameters Calibrated: Fabric presets match real measurements (Report).
- โ๏ธ Highly Stressed: We run GitHub Actions to run stress tests 10 times in a row.
- ๐ Massively Parallel: Both contact and elasticity solvers are run on the GPU.
- ๐ช Windows Executable: No installation wizard shown. Just unzip and run (Video).
- ๐ณ Docker Sealed: All can be deployed fast. The image is ~1GB.
- ๐ JupyterLab Included: Open your browser and run examples right away (Video).
- ๐ Documented Python APIs: Our Python code is fully docstringed and lintable (Video).
- โ๏ธ Cloud-Ready: Our solver can be seamlessly deployed on major cloud platforms.
- ๐จ Blender Add-on: Simulate remotely and fetch results locally, even on macOS.
- ๐ค MCP Support: Let a LLM run simulations for you using natural language.
- โจ Stay Clean: You can remove all traces after use.
- ๐ Permissive License: Apache 2.0 allows commercial and proprietary use.
โ ๏ธ Built for offline uses; not real time. Some examples may run at an interactive rate.
๐ Table of Contents
- ๐ Change History
- ๐ Technical Materials
- โก๏ธ Requirements
- ๐จ Getting Started
- ๐ How To Use
- ๐ Obtaining Logs
- ๐ผ๏ธ Catalogue
- ๐ GitHub Actions
- ๐ก Deploying on Cloud Services
- ๐ค Community Response
- ๐ผ Commercial Use and Beyond
- ๐ฌ Contributing
- ๐ฌ Participating Discussions
- ๐จ Reaching the Author
- ๐ Acknowledgements
๐ Advanced Contents
- ๐ง Setting Up Your Development Environment (Markdown)
- ๐ Bug Fixes and Updates (Markdown)
๐ Change History
- (2026.07.09) Added a performance article benchmarking the speedup across all examples.
- (2026.06.30) Added rigidbody support based on Painless Differentiable Rotation Dynamics.
- (2026.06.30) Added sand support.
- (2026.06.30) Achieved 2x performance improvement with help of feedback from @Hurleyworks.
- (2026.04.30) Added a Blender Add-on support. See the documentation.
๐ For the complete change history, see articles/changes.md.
๐ Technical Materials
๐ A Cubic Barrier with Elasticity-Inclusive Dynamic Stiffness
siga2024-main-mini.mp4
- ๐ Published in ACM Transactions on Graphics (TOG) Vol.43, No.6
- ๐ฅ Main video (Video)
- ๐ฅ Additional video examples (Directory)
- ๐ฅ Presentation videos (Short) (Long)
- ๐ Main paper (PDF) (Hindsight)
- ๐ Supplementary PDF (PDF)
- ๐ค Supplementary scripts (Directory)
- ๐ Singular-value eigenanalysis (Markdown)
๐ Reference Implementation
The main branch is undergoing frequent updates and will deviate from the paper.
To retain consistency with the paper, we have created a new branch sigasia-2024.
- ๐ ๏ธ Only maintenance updates are planned for this branch.
- ๐ซ General users should not use this branch as it is not optimized for best performance.
- ๐ซ All algorithmic changes listed in this (Markdown) are excluded from this branch.
- ๐ฆ We also provide a pre-compiled Docker image:
ghcr.io/st-tech/ppf-contact-solver-compiled-sigasia-2024:latestof this branch. - ๐ Template Link for vast.ai
โก๏ธ Requirements
- ๐ฅ An NVIDIA GPU with CUDA 12.8 or newer support. The RTX 4090 or 5090 is ideal for large-scale simulations, while the RTX 3090, 4070, or 5070 remains suitable for small to medium-scale workloads.
- ๐ป x86 architecture (arm64 is not supported)
- ๐ณ A Docker environment (see below) or ๐ช Windows 10/11 for native executable (see below)
- ๐จ Blender 5+ (only if you intend to use the Blender add-on)
๐จ Getting Started
Whether you plan to use the Blender add-on or the JupyterLab interface, the solver engine itself must first be deployed. The steps below apply to both.
โ ๏ธ Do not runwarmup.pylocally. If you do, you are very likely to hit failures and find it difficult to cleanup.
๐ช Windows Native Executable
For Windows 10/11 users, a self-contained executable (~320MB) is available. No Python, Docker, or CUDA Toolkit installation is needed. All should simply work out of the box (Video).
๐ค If you are cautious, you can review the build workflow to verify safety yourself. We try to maximize transparency; we never build locally and upload.
- Install the latest NVIDIA driver (Link)
- Download the latest release from GitHub Releases and unzip
- Double click
start.bat
JupyterLab frontend will auto-start. You should be able to access it at http://localhost:8080.
๐ณ Docker (Linux and Windows)
Install a NVIDIA driver (Link) on your host system and follow the instructions below specific to the operating system to get a Docker running:
| ๐ง Linux | ๐ช Windows |
|---|---|
Install the Docker engine from here (Link). Also, install the NVIDIA Container Toolkit (Link). Just to make sure that the Container Toolkit is loaded, run sudo service docker restart. |
Install the Docker Desktop (Link). You may need to log out or reboot after the installation. After logging back in, launch Docker Desktop to ensure that Docker is running. |
Next, run the following command to start the container. If no edits are needed, just copy and paste:
๐ช Windows (PowerShell)
$MY_WEB_PORT = 8080 # JupyterLab port on your side
$MY_BLENDER_PORT = 9090 # Solver port for the Blender add-on
$IMAGE_NAME = "ghcr.io/st-tech/ppf-contact-solver-compiled:latest"
docker run --rm -it `
--name ppf-contact-solver `
--gpus all `
-p ${MY_WEB_PORT}:${MY_WEB_PORT} `
-p ${MY_BLENDER_PORT}:${MY_BLENDER_PORT} `
-e WEB_PORT=${MY_WEB_PORT} `
$IMAGE_NAME # Image size ~1GB๐ง Linux (Bash/Zsh)
MY_WEB_PORT=8080 # JupyterLab port on your side
MY_BLENDER_PORT=9090 # Solver port for the Blender add-on
IMAGE_NAME=ghcr.io/st-tech/ppf-contact-solver-compiled:latest
docker run --rm -it \
--name ppf-contact-solver \
--gpus all \
-p ${MY_WEB_PORT}:${MY_WEB_PORT} \
-p ${MY_BLENDER_PORT}:${MY_BLENDER_PORT} \
-e WEB_PORT=${MY_WEB_PORT} \
$IMAGE_NAME # Image size ~1GBThe image download shall be started. Our image is hosted on GitHub Container Registry (~1GB). JupyterLab will then auto-start. Eventually you should be seeing:
==== JupyterLab Launched! ๐ ====
http://localhost:8080
Press Ctrl+C to shutdown
================================
Next, open your browser and navigate to http://localhost:8080. The port 8080 can change if you change the MY_WEB_PORT variable.
Keep your terminal window open.
Now you are ready to go! ๐
๐ Shutting Down
To shut down the container, just press Ctrl+C in the terminal.
The container will be removed and all traces will be cleaned up. ๐งน
If you wish to keep the container running in the background, replace
--rmwith-d. To shutdown the container and remove it, rundocker stop ppf-contact-solver && docker rm ppf-contact-solver.
๐ง Advanced Installation
If you wish to build the docker image from scratch, please refer to the cleaner installation guide (Markdown).
๐ How To Use
We provide two frontends: a Blender add-on and a JupyterLab interface. The Blender add-on lets you build scenes and run simulations entirely within Blender's UI, while JupyterLab lets you script everything in Python from your browser. Both communicate with the same solver engine, so pick whichever you like.
In both cases, you can interact with the simulator on your laptop while the actual simulation runs on a remote headless server over the internet. This means that you don't have to own NVIDIA hardware, but can rent it at vast.ai for less than $0.5 per hour. That said, if you do have a modern NVIDIA GPU on a local Windows or Linux machine, you can also run the solver directly on it. Actually, this (Video) was recorded on a vast.ai instance. The experience is good! ๐
๐จ Blender Add-on
Our Blender add-on aims to offer a familiar UI that best feels like everything works locally, but under the hood, it communicates with a remote server where all simulations run, and then the results are fetched back.
This provides a unique experience where users can leverage powerful remote GPUs while working seamlessly in their local Blender environment. Remarkably, our Blender add-on works even on macOS systems ๐, unlike other CUDA-based physics simulator add-ons that require local NVIDIA GPUs. More importantly, you can work on a laptop without worrying about draining the battery fast. ๐
Follow this page How to Install to learn how to install the add-on. For a thorough walk through workflow, we refer to our documentation below:
๐ฌ Prefer video? CGMatter walks through the full setup in this video tutorial:
Here are some highlights:
๐ Docs Look
We maintain a full docs site with workflow guides and recorded walkthroughs for the add-on:
![]() |
![]() |
| Workflow documentation page. (Link) | Video tutorials page. (Link) |
๐ผ๏ธ UI Look
Here are a couple of screenshots of the add-on running inside Blender:
![]() |
![]() |
| Kite scene set up in Blender. (full-size) | Zebra scene set up in Blender. (full-size) |
๐ค From Natural Language to Simulation (via MCP)
We expose all of the add-on's tools through an MCP server, so any LLM (Claude, Codex, etc.) can drive the whole pipeline from a natural language prompt. Scene building, parameter tweaks, and running the simulation all happen without UI clicks. Here are two examples:
![]() |
![]() |
| Codex (left) driving Blender (right) through the add-on's MCP server. | A prompt: drape a sheet over a sphere and make an animation video mp4 render 300 frames. |
๐ From a Python Script to Simulation
You can also drive the entire pipeline from a Python script inside Blender's scripting editor. This is handy for procedural scene setup and batch variant generation. Below is a full example that drapes a sheet over a sphere:
import addon_utils
import importlib
import bpy
# Look up the add-on module under whichever extension repository Blender
# installed it into and grab the public solver API.
addon = next(m for m in addon_utils.modules() if m.__name__.endswith(".ppf_contact_solver"))
solver = importlib.import_module(f"{addon.__name__}.ops.api").solver
# Reset any prior state.
solver.clear()
# Create a sphere (the static collider) at the origin.
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=4, radius=0.5, location=(0, 0, 0))
bpy.context.object.name = "Sphere"
# Create a 2x2 sheet just above the sphere as a 64x64 grid.
bpy.ops.mesh.primitive_grid_add(x_subdivisions=64, y_subdivisions=64, size=2, location=(0, 0, 0.6))
sheet = bpy.context.object
sheet.name = "Sheet"
# Pin the two corners on the -x edge via a vertex group.
vg = sheet.vertex_groups.new(name="Corners")
corner_indices = [
i for i, v in enumerate(sheet.data.vertices)
if v.co.x < -0.99 and abs(abs(v.co.y) - 1.0) < 0.01
]
vg.add(corner_indices, 1.0, "REPLACE")
# Build solver groups.
cloth = solver.create_group("Cloth", type="SHELL")
cloth.add("Sheet")
cloth.param.enable_strain_limit = True
cloth.param.strain_limit_percent = 5.0
cloth.param.bend = 1
ball = solver.create_group("Ball", type="STATIC")
ball.add("Sphere")
# Pin the two sheet corners.
cloth.create_pin("Sheet", "Corners")
# Scene parameters.
solver.param.frame_count = 100
solver.param.step_size = 0.01Here's how the script runs inside Blender (full-size):
For the full solver.* surface, see the Blender Python API guide.
๐ JupyterLab
Our frontend is accessible through a browser using our built-in JupyterLab interface. All is set up when you open it for the first time. Results can be interactively viewed through the browser and exported as needed. Our Python interface is designed with the following principles in mind:
- ๐ ๏ธ In-Pipeline Tri/Tet Creation: Depending on external 3D/CAD softwares for triangulation or tetrahedralization makes dynamic resolution changes cumbersome. We provide handy
.triangulate()and.tetrahedralize()calls to keep everything in-pipeline, allowing users to skip explicit mesh exports to 3D/CAD software. - ๐ซ No Mesh Data Included: Preparing mesh data using external tools can be cumbersome. Our frontend minimizes this effort by allowing meshes to be created on the fly or downloaded when needed.
- ๐ Method Chaining: We adopt the method chaining style from JavaScript, making the API intuitive to understand and read smoothly.
- ๐ฆ Single Import for Everything: All frontend features are accessible by simply importing with
from frontend import App.
Here's an example of draping five sheets over a sphere with two corners pinned. We have more examples in the examples directory. Please take a look! ๐
# import our frontend
from frontend import App
# make an app
app = App.create("drape")
# create a square mesh resolution 128 spanning the xz plane
V, F = app.mesh.square(res=128, ex=[1, 0, 0], ey=[0, 0, 1])
# add to the asset and name it "sheet"
app.asset.add.tri("sheet", V, F)
# create an icosphere mesh radius 0.5
V, F = app.mesh.icosphere(r=0.5, subdiv_count=4)
# add to the asset and name it "sphere"
app.asset.add.tri("sphere", V, F)
# create a scene
scene = app.scene.create()
# define gap between sheets
gap = 0.01
for i in range(5):
# add the sheet asset to the scene with an vertical offset
obj = scene.add("sheet").at(0, gap * i, 0)
# pick two corners
corner = obj.grab([1, 0, -1]) + obj.grab([-1, 0, -1])
# pin the corners
obj.pin(corner)
# set the strict limit on maximum strain to 5% per triangle
obj.param.set("strain-limit", 0.05)
# add a sphere mesh at a lower position with jitter and set it static collider
scene.add("sphere").at(0, -0.5 - gap, 0).jitter().pin()
# compile the scene and report stats
scene = scene.build().report()
# preview the initial scene, shows image left
scene.preview()
# create a new session with the compiled scene
session = app.session.create(scene)
# set session params
session.param.set("frames", 100).set("dt", 0.01)
# build this session
session = session.build()
# start the simulation and live-preview the results, shows image right
session.start().preview()
# also show streaming logs
session.stream()
# or interactively view the animation sequences
session.animate()
# export all simulated frames in (sequences of ply meshes + a video)
session.export.animation()๐ Python APIs and Parameters
-
Full API documentation is available on our GitHub Pages. The major APIs are documented using docstrings and compiled with Sphinx We have also included
jupyter-lspto provide interactive linting assistance and display docstrings as you type. See this video (Video) for an example. The behaviors can be changed through the settings. -
A list of parameters used in
param.set(key,value)is documented here: (Simulation Parameters) (Material Parameters).
โ ๏ธ Please note that our Python APIs are subject to breaking changes as this repository undergoes frequent iterations. If you need APIs to be fixed, please fork.
๐ Obtaining Logs
Logs for the simulation can also be queried through our Python APIs. Here's an example of how to get a list of recorded logs, fetch them, and compute the average.
# get a list of log names
logs = session.get.log.names()
print(logs)
assert "time-per-frame" in logs
assert "newton-steps" in logs
# get a list of time per video frame
msec_per_video = session.get.log.numbers("time-per-frame")
# compute the average time per video frame
print("avg per frame:", sum([n for _, n in msec_per_video]) / len(msec_per_video))
# get a list of newton steps
newton_steps = session.get.log.numbers("newton-steps")
# compute the average of consumed newton steps
print("avg newton steps:", sum([n for _, n in newton_steps]) / len(newton_steps))
# Last 8 lines. Omit for everything.
print("==== log stream ====")
for line in session.get.log.stdout(n_lines=8):
print(line)Below are some representatives.
vid_time refers to the video time in seconds and is recorded as float.
ms refers to the consumed simulation time in milliseconds recorded as int.
vid_frame is the video frame count recorded as int.
| Name | Description | Format |
|---|---|---|
| time-per-frame | Time per video frame | list[(vid_frame,ms)] |
| matrix-assembly | Matrix assembly time | list[(vid_time,ms)] |
| pcg-linsolve | Linear system solve time | list[(vid_time,ms)] |
| line-search | Line search time | list[(vid_time,ms)] |
| time-per-step | Time per step | list[(vid_time,ms)] |
| newton-steps | Newton iterations per step | list[(vid_time,count)] |
| num-contact | Contact count | list[(vid_time,count)] |
| max-sigma | Max stretch | list(vid_time,float) |
The full list of log names and their descriptions is documented here: (GitHub Pages).
Note that some entries have multiple records at the same video time. This occurs because the same operation is executed multiple times within a single step during the inner Newton's iterations. For example, the linear system solve is performed at each Newton's step, so if multiple Newton's steps are executed, multiple linear system solve times appear in the record at the same video time.
If you would like to retrieve the raw log stream, you can do so by
# Last 8 lines. Omit for everything.
for line in session.get.log.stdout(n_lines=8):
print(line)This will output something like:
* dt: 1.000e-03
* max_sigma: 1.045e+00
* avg_sigma: 1.030e+00
------ newton step 1 ------
====== contact_matrix_assembly ======
> dry_pass...0 msec
> rebuild...7 msec
> fillin_pass...0 msec
If you would like to read stderr, you can do so using session.get.log.stderr() (if it exists).
This returns list[str].
All the log files are updated in real-time and can be fetched right after the simulation starts; you don't have to wait until it finishes.
๐ผ๏ธ Catalogue
๐จ Blender Add-on Examples
These scenes are all built with our add-on. The simulation itself runs on a remote solver, or directly on your local machine if you have a modern NVIDIA GPU on Windows or Linux.
You set the geometry, constraints, and parameters from Blender's UI, and the saved .blend carries everything the add-on needs.
| kite.blend (Video) | crumple.blend (Video) | puff.blend (Video) |
![]() |
![]() |
![]() |
| press.blend (Video) | zebra.blend (Video) | curtain.blend (Video) |
![]() |
![]() |
![]() |
The simulated portion (objects, groups, pins, and solver parameters) is generated by a script you drop into Blender's Scripting editor. Cameras, lighting, and any non-simulated props are still set up in Blender's UI. Each script is linked above its thumbnail.
| cards.py (Video) | five-twist.py (Video) | noodle.py (Video) | woven.py (Video) |
![]() |
![]() |
![]() |
![]() |
๐ JupyterLab Examples
All these examples run on our Python frontend through JupyterLab. Click any notebook to see how the scene is built, or click the video link to watch the result.
๐ฐ Budget Table on AWS
Below is a table summarizing the estimated costs for running our examples on a NVIDIA L4 instance g6.2xlarge at Amazon Web Services US regions (us-east-1 and us-east-2).
- ๐ฐ Uptime cost is approximately $1 per hour.
- โณ Deployment time is approximately 8 minutes ($0.13). Instance loading takes 3 minutes, and Docker pull & load takes 5 minutes.
- ๐ฎ The NVIDIA L4 delivers 30.3 TFLOPS for FP32, offering approximately 36% of the performance of an RTX 4090.
- ๐ฅ Video frame rate is 60fps.
| Example | Cost | Time | #Frame | #Vert | #Face | #Tet | #Rod | Max Strain |
|---|---|---|---|---|---|---|---|---|
| trapped | $0.08 | 4.66m | 300 | 135.4K | 216.5K | 416.5K | N/A |
N/A |
| twist | $0.37 | 22.4m | 430 | 204.0K | 406.8K | N/A |
N/A |
N/A |
| stack | $0.28 | 16.5m | 120 | 167.4K | 327.7K | 12.7K | N/A |
5% |
| trampoline | $0.13 | 8.03m | 60 | 36.4K | 56.8K | 78.2K | N/A |
1% |
| needle | $0.20 | 11.7m | 120 | 86.8K | 169.0K | 12.7K | N/A |
5% |
| cards | $0.08 | 4.54m | 180 | 9.4K | 13.9K | 6.0K | N/A |
N/A |
| domino | $0.06 | 3.38m | 250 | 0.5K | 0.8K | 0.3K | N/A |
N/A |
| drape | $0.04 | 2.67m | 100 | 81.9K | 161.3K | N/A |
N/A |
5% |
| curtain | $0.18 | 10.6m | 300 | 64.0K | 124.2K | N/A |
N/A |
5% |
| friction | $0.16 | 9.63m | 850 | 3.3K | 4.3K | 12.0K | N/A |
N/A |
| hang | $0.14 | 8.54m | 200 | 16.4K | 32.3K | N/A |
N/A |
1% |
| belt | $0.08 | 5.09m | 200 | 12.3K | 23.3K | N/A |
N/A |
5% |
| codim | $0.11 | 6.80m | 240 | 61.6K | 73.6K | 234.5K | 1.3K | N/A |
| fishingknot | $0.08 | 4.89m | 350 | 19.6K | 36.9K | N/A |
N/A |
5% |
| fitting | $0.02 | 1.26m | 240 | 28.5K | 54.9K | N/A |
N/A |
10% |
| noodle | $0.09 | 5.25m | 240 | 116.3K | N/A |
N/A |
116.2K | N/A |
| ribbon | $0.25 | 14.8m | 480 | 35.6K | 53.0K | 12.6K | N/A |
N/A |
| woven | $0.39 | 23.2m | 450 | 115.6K | N/A |
N/A |
115.4K | N/A |
| yarn | $0.01 | 0.25m | 120 | 28.6K | N/A |
N/A |
28.5K | N/A |
| roller | $0.04 | 2.64m | 180 | 6.2K | 11.0K | 7.1K | N/A |
N/A |
๐๏ธ Large Scale Examples
Large scale examples are run on a vast.ai instance with an RTX 4090. These examples are not included in GitHub Action tests since they can take days to finish.
| large-twist.ipynb (Video) | large-five-twist.ipynb (Video) | large-woven.ipynb (Video) |
![]() |
![]() |
![]() |
| Example | Commit | #Vert | #Face | #Rod | #Contact | #Frame | Time/Frame |
|---|---|---|---|---|---|---|---|
| large-twist | cbafbd2 | 3.2M | 6.4M | N/A |
56.7M | 2,000 | 46.4s |
| large-five-twist | 6ab6984 | 8.2M | 16.4M | N/A |
184.1M | 2,413 | 144.5s |
| large-woven | 4c07b83 | 2.7M | N/A |
2.7M | 8.9M | 946 | 436.8s |
๐ Large scale examples take a very long time, and it's easy to lose connection or close the browser. Our frontend lets you close and reopen it at your convenience. Just recover your session after you reconnect. Here's an example cell how to recover:













































