Arrow3D: expose the cone's resolution (currently unconfigurable, 95% of the arrow's submobjects)
Description of proposed feature
Arrow3D builds its conical tip as a Cone, which inherits Surface's default
resolution=32. That is 1024 quad submobjects for a tip that is about 22 × 40 px on a
1080p frame — 95% of every submobject the arrow contains. Arrow3D does accept a
resolution argument, but it is applied to the shaft only, so the tip's mesh density cannot
be changed through any public API.
I'd like to propose exposing the tip's resolution and giving it a default in proportion to the object's size. On scenes containing arrows this is worth roughly a 2.8×–4.6× speedup, with no visible difference at 1080p.
Arrow3D.__init__ declares resolution as a named parameter, so it never lands in
**kwargs — and **kwargs is the only thing forwarded to the Cone:
# manim/mobject/three_d/three_dimensions.py:1243
def __init__(
self,
...
resolution: int | tuple[int, int] = 24, # <- consumed here
**kwargs: Any,
) -> None:
super().__init__(..., resolution=resolution, **kwargs) # <- goes to Line3D
...
self.cone = Cone(
direction=self.direction,
base_radius=base_radius,
height=height,
**kwargs, # <- 'resolution' is not in here
)Cone.__init__ (:674) does not declare resolution either, so it forwards to Surface
(:118), whose default is 32. So the tip always gets 32.
The docstring is accurate about this — it documents resolution as "The resolution of the
arrow line." I'm not claiming the behaviour is wrong; the issue is that the tip has no knob
at all, and its inherited default is out of proportion for the object's size (1024 quads for
0.042% of the frame).
How can the new feature be used?
Add a separate parameter for the tip, rather than overloading resolution (a (u, v) tuple
meant for a line cylinder has no sensible meaning for a cone):
def __init__(
self,
start: Point3DLike = LEFT,
end: Point3DLike = RIGHT,
thickness: float = 0.02,
height: float = 0.3,
base_radius: float = 0.08,
color: ParsableManimColor = WHITE,
resolution: int | tuple[int, int] = 24,
+ cone_resolution: int | tuple[int, int] = (8, 16),
**kwargs: Any,
) -> None:
@@
self.cone = Cone(
direction=self.direction,
base_radius=base_radius,
height=height,
+ resolution=cone_resolution,
**kwargs,
)With cone_resolution defaulting to (8, 16), the tip drops from 1026 submobjects to 130,
and the whole arrow from 1079 to 183.
Cone's u_range is (u_min, slant_length) and its v_range is the azimuthal angle, so the
first number controls subdivisions along the slant and the second around the circumference.
(8, 16) keeps 16 segments around the base — the direction where faceting shows on a
silhouette — while cutting the slant direction, where 8 is plenty.
Additional comments
Reproduction
from manim import *
from manim.mobject.three_d.three_dimensions import Cone
def count(m):
return 1 + sum(count(s) for s in m.submobjects)
a = Arrow3D(start=ORIGIN, end=RIGHT)
print(count(a)) # 1079
print(count(a.cone)) # 1027
print(len(a.cone.submobjects)) # 1026 (1024 mesh quads + 2 VectorizedPoints)
# 'resolution' is accepted by Arrow3D but never reaches the cone:
for kw in [{}, {"resolution": 4}, {"resolution": (4, 4)}, {"resolution": 32}, {"resolution": (8, 16)}]:
print(kw, len(Arrow3D(start=ORIGIN, end=RIGHT, **kw).cone.submobjects))Observed (manim v0.21.0, cairo renderer) — all five print 1026:
1079
1027
1026
{} 1026
{'resolution': 4} 1026
{'resolution': (4, 4)} 1026
{'resolution': 32} 1026
{'resolution': (8, 16)} 1026Arrow3D(resolution=(8, 16)) hands that tuple to the shaft (Line3D's (2, N) reduction
only kicks in when an int is passed, :1010) and still leaves the tip at 32 × 32.
The total is u_res * v_res mesh quads for the underlying Surface, plus the cone's two
VectorizedPoints:
Cone(resolution=4) # 18 submobjects = 4*4 + 2
Cone(resolution=(4, 8)) # 34 submobjects = 4*8 + 2
Cone(resolution=(8, 16)) # 130 submobjects = 8*16 + 2
Cone(resolution=32) # 1026 submobjects = 32*32 + 2 <- what Arrow3D's tip getsImpact
Measured at 1920×1080 with a static camera, wait(1) = 15 frames, timing only the in-process
Scene.render() (manim's ~1.6 s startup excluded), 3 repetitions, median reported:
| scene contents | stock cone (32×32) | cone at (8, 16) | speedup |
|---|---|---|---|
1 × Arrow3D |
25.4 ms/frame | 9.0 ms/frame | 2.8× |
5 × Arrow3D |
109.4 ms/frame | 24.0 ms/frame | 4.6× |
Put differently: five arrows in a 10-second animation at 30 fps (300 frames) cost ~26 extra seconds of render time for cone detail that is not resolvable on screen.
The default tip is height=0.3, base_radius=0.08. At 1080p with frame_height = 8 that is
a 22 × 40 px object: 0.042% of the frame, drawn as 1024 separate quad mobjects, each going
through depth sorting and rasterization on every frame.
The same effect in a real scene: a 1274-frame, 59-animation teaching sequence at 854×480
containing ThreeDAxes, a parametric surface, a cone, and four Arrow3D went from 186.7 s
to 54.7 s (3.41×) when only the tip's mesh density changed. Over four such scenes the ratio
ranged from 2.35× to 3.41×.
Precedents already in the codebase
This is the same argument the project has already accepted elsewhere:
Line3Dhard-codes its own reduction in__init__(:1010):self.resolution = (2, resolution) if isinstance(resolution, int) else resolutioni.e. a line cylinder is only ever subdivided twice along its length, because subdividing along the height cannot add visual detail. The corresponding commit in #1026 describes it as reducing the submobject count "by 12x while sacrificing no quality". The arrow's tip is exactly the same situation and simply did not get the same treatment.
Dot3D, another small 3D mobject, explicitly defaults toresolution=(8, 8)(:524) rather than theSurfacedefault.Sphere(:448) andTorus(:1321) do not hard-code a number at all; they pick a resolution based onconfig.renderer((24, 12)on cairo,(101, 51)on OpenGL).
So the codebase already treats "small mobject → lower resolution" and "resolution is a
renderer-dependent budget" as normal. Arrow3D's tip is the case that misses both.
On the default value
I compared renders side by side at 854×480 (zoomed 4×, nearest neighbour) and at 1920×1080
(zoomed 2×): stock vs (8, 16) was not distinguishable by eye in either. Dropping to (4, 8)
was distinguishable — visible polygonal faceting on the cone's surface. Hence (8, 16) as
the recommendation, with (4, 8) as the practical floor. This is a visual judgement, not a
measurement, so it's worth a second pair of eyes.
Backwards compatibility
- The new parameter is additive; existing code keeps working unchanged.
- The default render output changes slightly — this is the one thing to decide on. The
Line3Dchange in #1026 took the same trade ((2, N)is also a default change) and was accepted on the grounds that the removed detail was not visible. - Existing graphical-unit tests for
Arrow3Dmay need reference images regenerated. I have not checked whether the difference is large enough to trip the test suite.
If maintainers would rather not change any default, the conservative alternative is to ship
cone_resolution defaulting to 32 (output stays byte-identical, the knob is merely
available). I'd argue against it: 1026 quads is the inherited Surface default nobody
chose, and most users will never discover the parameter.
Workaround today
Monkeypatch the module global — patching manim.Cone does not work, because
Arrow3D.__init__ resolves Cone in three_dimensions' namespace:
import manim.mobject.three_d.three_dimensions as td # effective
# import manim; manim.Cone = ... # silently does nothing
class _FastCone(td.Cone):
def __init__(self, *args, **kwargs):
kwargs.setdefault("resolution", (8, 16))
super().__init__(*args, **kwargs)
td.Cone = _FastConeI've been shipping this patch in a lecture-video pipeline; it's what led me to look upstream.
Questions
- Is
cone_resolutionthe right API shape, or would you prefer to fold this into the renderer-dependent resolution convention thatSphere/Torusalready use? - Is changing the default to
(8, 16)acceptable, or should the default stay at 32? - Happy to open the PR (with docs and a changelog entry) once the shape is agreed.
I searched for an existing issue covering this and found only the Line3D half of the
argument in #1026 — apologies if I missed
a duplicate; please point me at it.
import statistics, time
from manim import *
config.pixel_width, config.pixel_height, config.frame_rate = 1920, 1080, 15
config.disable_caching = True
class Bench(ThreeDScene):
def construct(self):
self.set_camera_orientation(phi=70 * DEGREES, theta=-45 * DEGREES)
for _ in range(5):
self.add(Arrow3D(start=ORIGIN, end=[1, 1, 1], color=RED))
self.wait(1) # 15 frames
reps = []
for _ in range(3):
t = time.perf_counter()
Bench().render()
reps.append(time.perf_counter() - t)
print(statistics.median(reps) / 15 * 1000, "ms/frame")Time it in-process, not by timing the manim CLI: startup is ~1.6 s here, which dwarfs a
15-frame measurement window and produced results that were stable but wrong until I moved the
timer inside the process.
Source: ManimCommunity/manim