#5020·manim

Arrow3D: expose the cone's resolution (currently unconfigurable, 95% of the arrow's submobjects)

Author: yanhanccnuCreated Sep 17, 2026Updated Sep 17, 2026
Labelsnew feature

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:

python
# 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):

diff
     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

python
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)} 1026

Arrow3D(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:

python
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 gets

Impact

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:

  • Line3D hard-codes its own reduction in __init__ (:1010):

    python
    self.resolution = (2, resolution) if isinstance(resolution, int) else resolution

    i.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 to resolution=(8, 8) (:524) rather than the Surface default.

  • Sphere (:448) and Torus (:1321) do not hard-code a number at all; they pick a resolution based on config.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 Line3D change 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 Arrow3D may 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:

python
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 = _FastCone

I've been shipping this patch in a lecture-video pipeline; it's what led me to look upstream.

Questions

  1. Is cone_resolution the right API shape, or would you prefer to fold this into the renderer-dependent resolution convention that Sphere/Torus already use?
  2. Is changing the default to (8, 16) acceptable, or should the default stay at 32?
  3. 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.

Measurement script
python
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.