# 3D isosurface plotting in Makie

**URL:** <https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470>\
**Category:** Visualization\
**Tags:** makie\
**Created:** [August 8, 2025, 9:50am UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470 "2025-08-08T09:50:37Z")\
**Posts on this page:** 15\
**Page:** 1

<div class="post-metadata">

**Author:** ![mreichMPI-BGC](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/mreichmpi-bgc/32/43775_2.png) [@mreichMPI-BGC](https://discourse.julialang.org/u/mreichMPI-BGC)\
**Post date:** [August 8, 2025, 9:50am UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/1 "2025-08-08T09:50:37Z")

</div>

I am using the (semi-)documented contour function for 3D-isosurfaces, cf. [volume | Makie](https://docs.makie.org/dev/reference/plots/volume), first example.

One cannot define vector of alphas (which would be nice because outer surfaces (shells) “should” be more transparent.

Thus I came up with the following code:

```julia-auto
using Statistics, GLMakie, LinearAlgebra

_iscollection(x) = x isa Union{AbstractArray, Tuple, AbstractRange}
# Cycle collections; repeat scalars
_as_iter(x, n) = _iscollection(x) ? Iterators.take(Iterators.cycle(x), n) :
                                   Iterators.repeated(x, n)

function new_iso_contour3d!(
    vol::AbstractArray;
    xs = (1, size(vol, 1)), ys = (1,size(vol, 2)), zs = (1,size(vol, 3)),
    ax = current_axis(),
    levels = quantile(vol, [0.25, 0.50, 0.75]),
    colors = [:yellow, :orange, :red],
    alphas = [0.05, 0.1, 0.2],
)

    lvls = levels isa Number ? (levels, ) : collect(levels)
    L = length(lvls)
    cols = collect(_as_iter(colors, L))
    als = collect(_as_iter(alphas, L))

    ivx, ivy, ivz = extrema(xs), extrema(ys), extrema(zs)

    for (lev, col, a) in zip(lvls, cols, als)
        contour!(ax, ivx, ivy, ivz, vol;
                 levels=[lev], colormap=cgrad([col, col]), alpha=a)
    end
    nothing
end

r = range(-1, 1, length=20)
vol = [norm([1.2x,y,z]) for x=r, y=r, z=r]
fig = Figure()
ax = LScene(fig[1, 1], scenekw=(show_axis=true,))
new_iso_contour3d!(vol[1:10,:,:]; xs=0.5r, ys=r, zs=r,
    levels=[0.1, 0.5, 0.8], colors=[:red, :orange, :yellow], alphas=[0.2, 0.1, 0.05])

```

Now, my problem is that the isosurfaces are very thick:

 ![image](https://global.discourse-cdn.com/julialang/original/3X/2/6/26295f6484f9fd3837c09e0daf99aeba16efe86b.jpeg)

I also tried volume(algorithm=:iso,…) and then a thin isorange, but with smaller alpha the surfaces are really grey. So it would be nice to have control over isorange also from the contour function (?).

I want to plot something, that looks similar to this one below (some years ago with R):

 ![movie_060](https://global.discourse-cdn.com/julialang/original/3X/7/6/7666a9e48bad716cc10d2e2eda4791cc5789d402.gif)

Maybe it is just a matter of the settings in volume(algorithm=:iso,…). Or maybe one can do with `surface`, but I would not know how exactly. (still new to Julia).

Thanks for any help!

---

<div class="post-metadata">

**Author:** ![raman\_kumar](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/raman_kumar/32/26782_2.png) [@raman\_kumar](https://discourse.julialang.org/u/raman_kumar)\
**Post date:** [August 8, 2025, 1:49pm UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/2 "2025-08-08T13:49:17Z")

</div>

> <https://github.com/MakieOrg/Makie.jl/pull/5213>
>
> \# Description
> 
> Mildly related to #5194
> 
> This pr updates \`contour(..., ::Arra…y{Float, 3})\` to rely on the compute graph and introduced some changes to the colormapping:
> \- \[change\] ~~\`colorrange\` now defaults to the extrema of \`levels\` rather than the extrema of the given data~~
> \- \[fix\] \`colormap\` used to always generate colors for the full value range \`minimum(volumedata) .. maximum(volumedata)\`, but report \`colorrange\` as its limits to volume. This made any 3D contour with a non default colorrange incorrect. Now the colormap generates for slightly padded colorrange (to include a transparent min and max color) which gets passed down correctly.
> \- \[fix\] \`isorange\` is now a setable attribute again
> \- \[change\] default \`isorange\` no longer assumes uniform levels. Instead it's generated based on the minimum distance between levels
> \- \[internal change\] The number of colors in the colormap is no longer based on the number of levels. Instead it aims to resolve the padded colorrange in steps of isorange, so that each iso surface has at least one non-transparent color mapped to it
> \- \[change\] Colorbar now looks at colormap alpha as a binary so that transparent contours still have a readable Colorbar
> 
> By example:
> \`\`\`julia
> r = range(-1.2, 1.2, 21)
> data = Array{Float32, 3}(undef, 21, 21, 21)
> for (i, x) in enumerate(r)
> for (j, y) in enumerate(r)
> for (k, z) in enumerate(r)
> data\[i, j, k\] = norm(Vec3f(x,y,z))
> end
> end
> end
> f = Figure(size = (400, 1200))
> contour(f\[1, 1\], data, alpha = 0.1)
> contour(f\[2, 1\], data, alpha = 0.1, levels = 0:0.2:1)
> contour(f\[3, 1\], data, alpha = 0.1, colorrange = (0, 1))
> contour(f\[4, 1\], data, alpha = 0.1, levels = 0:0.2:1, colorrange = (0, 1))
> f
> \`\`\`
> 
> | before | after |
> | --- | --- |
> | \<img width="402" height="1232" alt="Screenshot 2025-07-28 160442" src="https://github.com/user-attachments/assets/068ea75c-ed5e-49e9-adc3-336f947a06d4" /\> | \<img width="402" height="1232" alt="Screenshot 2025-07-28 180651" src="https://github.com/user-attachments/assets/75cb924e-5308-410b-ab33-0b43c927e559" /\> |
> 
> \- ~~1, 2 show off different colorrange defaults~~
> \- 3, 4 show off the colormap - colorrange missmatch
> 
> TODO:
> \- \[x\] cleanup Colorbar for 3D contour
> \- \[x\] reconsider default colorrange (should it continue to look at the full value range instead?)
> \- \[x\] reconsider color sample density (is 1-2 colors per sample enough?)
> 
> \## Type of change
> 
> \- \[x\] Bug fix (non-breaking change which fixes an issue)
> 
> ~~... maybe breaking due to change in colorrange defaulting?~~
> 
> \## Checklist
> 
> \- \[x\] Added an entry in CHANGELOG.md (for new features and breaking changes)
> \- \[\] Added or changed relevant sections in the documentation
> \- \[\] Added unit tests for new algorithms, conversion methods, etc.
> \- \[x\] Added reference image tests for new plotting functions, recipes, visual options, etc.

---

<div class="post-metadata">

**Author:** ![eldee](https://avatars.discourse-cdn.com/v4/letter/e/b5a626/32.png) [@eldee](https://discourse.julialang.org/u/eldee)\
**Post date:** [August 8, 2025, 2:06pm UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/3 "2025-08-08T14:06:47Z")

</div>

Looking at the [source code](https://github.com/MakieOrg/Makie.jl/blob/master/Makie/src/basic_recipes/contours.jl) of `contour`:

```julia
function plot!(plot::Contour{<:Tuple{X, Y, Z, Vol}}) where {X, Y, Z, Vol}
    x, y, z, volume = plot[1:4]
    @extract plot (colormap, levels, linewidth, alpha)
    valuerange = lift(nan_extrema, plot, volume)
    cliprange = map((v, default) -> ifelse(v === automatic, default, v), plot, plot.colorrange, valuerange)
    cmap = lift(plot, colormap, levels, alpha, cliprange, valuerange) do _cmap, l, alpha, cliprange, vrange
        levels = to_levels(l, vrange)
        nlevels = length(levels)
        N = 50 * nlevels

        iso_eps = if haskey(plot, :isorange)
            plot.isorange[]
        else
            nlevels * ((vrange[2] - vrange[1]) / N) # TODO calculate this
        end
        cmap = to_colormap(_cmap)
        v_interval = cliprange[1] .. cliprange[2]
        # resample colormap and make the empty area between iso surfaces transparent
        map(1:N) do i
            i01 = (i - 1) / (N - 1)
            c = Makie.interpolated_getindex(cmap, i01)
            isoval = vrange[1] + (i01 * (vrange[2] - vrange[1]))
            line = reduce(levels, init = false) do v0, level
                isoval in v_interval || return false
                v0 || abs(level - isoval) <= iso_eps
            end
            RGBAf(Colors.color(c), line ? alpha : 0.0)
        end
    end

    return volume!(
        plot, Attributes(plot), x, y, z, volume, alpha = 1.0, # don't apply alpha 2 times
        algorithm = 7, colorrange = cliprange, colormap = cmap
    )
end

```

I guess the idea (for some reason, it seems quite inefficient) is that we check for ‘all’ values `isoval` between the minimum and maximum value of `volume` whether it lies sufficiently close (according to `iso_eps`) to one of the supplied `levels`. If so, we draw it using `volume!` (the source code of which I fail to locate) in a certain colour. (Now, in practice, we cannot check all possible values of course, so we restrict ourselves to a linear grid of `N` steps. If this grid is too coarse (for a given `iso_eps`), we might miss a certain `level`. Makie does not seem to supply any way to directly control `N`, but by supplying e.g. `[0.1, 0.1, 0.1]` instead of `[0.1]` for `levels` you do triple `N`. So getting a too coarse grid can be easily circumvented.)

Now suppose you would actually check all possible `isoval`s. Then your shells would have a thickness of `2 * iso_eps`. So we want to reduce `iso_eps`. But as far as I can tell, there’s currently no accessible way to do this. There are some hacky solutions possible though. I’m not fond of any of them as they constitute type piracy, but, hey, they do kind of work.

> **Overwrite Makie.plot!**
>
> Just increase `50` in the definition of `N` to e.g. `100`:
> 
> ```julia
> using Makie: nan_extrema, automatic, to_levels, Colors
> 
> function Makie.plot!(plot::Contour{<:Tuple{X, Y, Z, Vol}}) where {X, Y, Z, Vol}
> x, y, z, volume = plot[1:4]
> @extract plot (colormap, levels, linewidth, alpha)
> valuerange = lift(nan_extrema, plot, volume)
> cliprange = map((v, default) -> ifelse(v === automatic, default, v), plot, plot.colorrange, valuerange)
> cmap = lift(plot, colormap, levels, alpha, cliprange, valuerange) do _cmap, l, alpha, cliprange, vrange
> levels = to_levels(l, vrange)
> nlevels = length(levels)
> N = 100 * nlevels
> 
> iso_eps = if haskey(plot, :isorange)
> plot.isorange[]
> else
> nlevels * ((vrange[2] - vrange[1]) / N) # TODO calculate this
> end
> cmap = to_colormap(_cmap)
> v_interval = cliprange[1] .. cliprange[2]
> # resample colormap and make the empty area between iso surfaces transparent
> map(1:N) do i
> i01 = (i - 1) / (N - 1)
> c = Makie.interpolated_getindex(cmap, i01)
> isoval = vrange[1] + (i01 * (vrange[2] - vrange[1]))
> line = reduce(levels, init = false) do v0, level
> isoval in v_interval || return false
> v0 || abs(level - isoval) <= iso_eps
> end
> RGBAf(Colors.color(c), line ? alpha : 0.0)
> end
> end
> 
> return volume!(
> plot, Attributes(plot), x, y, z, volume, alpha = 1.0, # don't apply alpha 2 times
> algorithm = 7, colorrange = cliprange, colormap = cmap
> )
> end
> 
> ```

> **'Add' isorange**
>
> (As a side note, snake case would make clearer we’re not talking about colours or fruits here 🙂 .)
> 
> ```julia
> function Base.haskey(plot::Contour{<:Tuple{X, Y, Z, Vol}}, symb::Symbol) where {X, Y, Z, Vol}
> symb === :isorange && return true 
> invoke(haskey, Tuple{Plot, Symbol}, plot, symb)
> end
> 
> function Base.getproperty(plot::Contour{<:Tuple{X, Y, Z, Vol}}, symb::Symbol) where {X, Y, Z, Vol}
> symb == :isorange && return 0.01
> return invoke(Base.getproperty, Tuple{Plot, Symbol}, plot, symb)
> end
> 
> function new_iso_contour3d!(...)
> (...)
> contour!(...; levels=[lev, lev, lev], ...)
> (...)
> end
> 
> ```

Results, with `alpha=1` for clarity:  
(Before, overwriting `Makie.plot!`, ‘adding’ `isorange`)

 ![Before](https://global.discourse-cdn.com/julialang/original/3X/0/f/0f440d1f2499397318a9937c51d2a623da15b02f.png)  
 ![After_plot!](https://global.discourse-cdn.com/julialang/original/3X/9/4/9467e39fb24c6ad33cb54c3eb6812d55a9000673.png)  
 ![After_isorange](https://global.discourse-cdn.com/julialang/original/3X/0/c/0c5c2fb76eefd61497f664fae07fb1c435db2845.png)

---

<div class="post-metadata">

**Author:** ![mreichMPI-BGC](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/mreichmpi-bgc/32/43775_2.png) [@mreichMPI-BGC](https://discourse.julialang.org/u/mreichMPI-BGC)\
**Post date:** [August 10, 2025, 5:40am UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/4 "2025-08-10T05:40:59Z")

</div>

Thanks a lot for these insights! Yes, I could reproduce it with alphas 0.4, 0.2, 0.1 from inside to outside:

 ![image](https://global.discourse-cdn.com/julialang/original/3X/e/0/e0d77e7527ebcdc5b67c9c1e082a7a791e339415.jpeg)

The “look and feel” is still different from “real” surfaces I believe, e.g.

```julia-auto
Rs = [1, 2, 4]
fig = Figure(size=(1200, 800))
ax = LScene(fig[1, 1], scenekw=(show_axis=true,))
colors=[:red, :orange, :yellow]
alphas=[0.4, 0.2, 0.1]
φs = range(0, π, 20) # polar angle
θs = range(0, 2π, 40) # azimuth angle

for i in 1:3
  xs = Rs[i] .* sin.(φs) .* cos.(θs')
  ys = Rs[i] .* sin.(φs) .* sin.(θs')
  zs = Rs[i] .* cos.(φs) .* ones(length(θs), 1)'
  surface!(xs, ys, zs, alpha=alphas[i], color=fill(colors[i], zs |> size))
  wireframe!(xs, ys, zs, color=:black, linewidth=0.5)
end

```

 ![image](https://global.discourse-cdn.com/julialang/original/3X/8/a/8aeb34af4d4a885a2431b41ba23267bbda7f5e6e.jpeg)

But in the real case I don’t have the analytical surfaces of course. Also in the rendering of the surface there are strange shading patterns (that’s why I put the wireframe =\> it is not the surface arangement itself). So probably @eldee solution is the best and I can possibly tweak rendering parameters.

A question I have is: the isorange = 0.01 seems hardwired and can’t be set in a call `contour(..., isorange = 0.1, ...)` . My Julia insight it not good enough to see how to change it. Actually I thought with haskey and getproperty one could set it.

Don’t remember how the R plot was done, but it was also OpenGL based.

---

<div class="post-metadata">

**Author:** ![eldee](https://avatars.discourse-cdn.com/v4/letter/e/b5a626/32.png) [@eldee](https://discourse.julialang.org/u/eldee)\
**Post date:** [August 10, 2025, 11:06am UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/5 "2025-08-10T11:06:17Z")

</div>

> [@mreichMPI-BGC](#):
>
> A question I have is: the isorange = 0.01 seems hardwired and can’t be set in a call `contour(..., isorange = 0.1, ...)` . My Julia insight it not good enough to see how to change it. Actually I thought with haskey and getproperty one could set it.

There is probably a way to temporarily alter `getproperty` with the new value, and restore it afterwards. But the easiest option would be to use a global variable (maybe a `RefValue`):

```julia
using Statistics, GLMakie, LinearAlgebra

_iscollection(x) = x isa Union{AbstractArray, Tuple, AbstractRange}
# Cycle collections; repeat scalars
_as_iter(x, n) = _iscollection(x) ? Iterators.take(Iterators.cycle(x), n) :
                                   Iterators.repeated(x, n)

global_isorange::Float64 = 0.01

function Base.haskey(plot::Contour{<:Tuple{X, Y, Z, Vol}}, symb::Symbol) where {X, Y, Z, Vol}
    symb === :isorange && return true 
    invoke(haskey, Tuple{Plot, Symbol}, plot, symb)
end

function Base.getproperty(plot::Contour{<:Tuple{X, Y, Z, Vol}}, symb::Symbol) where {X, Y, Z, Vol}
    symb == :isorange && return global_isorange # <----
    return invoke(Base.getproperty, Tuple{Plot, Symbol}, plot, symb)
end

function new_iso_contour3d!(
    vol::AbstractArray;
    xs = (1, size(vol, 1)), ys = (1,size(vol, 2)), zs = (1,size(vol, 3)),
    ax = current_axis(),
    levels = quantile(vol, [0.25, 0.50, 0.75]),
    isorange = global_isorange, # (or calculate like in plot!(::Contour), and/or Vector{Float64})
    levels_factor = 1, 
    colors = [:yellow, :orange, :red],
    alphas = [0.05, 0.1, 0.2],
)

    global global_isorange

    lvls = levels isa Number ? (levels, ) : collect(levels)
    L = length(lvls)
    cols = collect(_as_iter(colors, L))
    als = collect(_as_iter(alphas, L))

    ivx, ivy, ivz = extrema(xs), extrema(ys), extrema(zs)

    old_isorange = global_isorange
    global_isorange = isorange
    for (lev, col, a) in zip(lvls, cols, als)
        contour!(ax, ivx, ivy, ivz, vol;
                 levels=fill(lev, levels_factor), colormap=cgrad([col, col]), alpha=a, 
                 transparency=true) # (looks a bit nicer with this set to true)
    end
    global_isorange = old_isorange
    nothing
end

r = range(-1, 1, length=20)
vol = [norm([1.2x,y,z]) for x=r, y=r, z=r]
fig = Figure()
ax = LScene(fig[1, 1], scenekw=(show_axis=true,))
new_iso_contour3d!(vol[1:10,:,:]; xs=0.5r, ys=r, zs=r, ax=ax,
           levels=[0.1, 0.5, 0.8], colors=[:red, :orange, :yellow], isorange=0.03, levels_factor=1, alphas=[0.2, 0.1, 0.05])

ax2 = LScene(fig[1, 2], scenekw=(show_axis=true,))
new_iso_contour3d!(vol[1:10,:,:]; xs=0.5r, ys=r, zs=r, ax=ax2,
           levels=[0.1, 0.5, 0.8], colors=[:red, :orange, :yellow], isorange=0.01, levels_factor=3, alphas=[0.2, 0.1, 0.05])

```

 ![](https://global.discourse-cdn.com/julialang/original/3X/0/f/0fe879d4a3d2a966968cbe36efe14ec495e6a376.png)

---

<div class="post-metadata">

**Author:** ![jules](https://avatars.discourse-cdn.com/v4/letter/j/41988e/32.png) [@jules](https://discourse.julialang.org/u/jules)\
**Post date:** [August 10, 2025, 3:29pm UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/6 "2025-08-10T15:29:38Z")

</div>

Building a mesh with MarchingCubes.jl could be an alternative:

```julia
using GLMakie
using MarchingCubes
using LinearAlgebra

##

r = range(-1, 1, length=20)
vol = [norm([1.2x,y,z]) for x=r, y=r, z=r]

function contourmesh!(ax, vol, level; kwargs...)
    m = MC(vol)
    march(m, level)
    msh = MarchingCubes.makemesh(Makie.GeometryBasics, m)
    return mesh!(ax, msh; kwargs...)
end

f = Figure()
ax = LScene(f[1, 1])

levels = [0.3, 0.5, 0.8]

for (i, l) in enumerate(levels)
    contourmesh!(ax, vol, l;
        alpha = [1, 0.5, 0.2][i],
        color = [:red, :orange, :yellow][i],
        transparency = true,
    )
end

f

```

 ![image](https://global.discourse-cdn.com/julialang/original/3X/5/2/52c64cda17008e649f637226815cb2acb7fba4e1.jpeg)

---

<div class="post-metadata">

**Author:** ![mreichMPI-BGC](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/mreichmpi-bgc/32/43775_2.png) [@mreichMPI-BGC](https://discourse.julialang.org/u/mreichMPI-BGC)\
**Post date:** [August 11, 2025, 4:46pm UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/7 "2025-08-11T16:46:37Z")

</div>

This is spot on! Also the “strange” pattern of surface aren’t there and material properties allow nice customization! Thanks!

---

<div class="post-metadata">

**Author:** ![sdanisch](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/sdanisch/32/1406_2.png) [@sdanisch](https://discourse.julialang.org/u/sdanisch)\
**Post date:** [August 11, 2025, 5:19pm UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/8 "2025-08-11T17:19:55Z")

</div>

We should really add that example to the volume docs! This questions comes up every month or so 😉

---

<div class="post-metadata">

**Author:** ![ffreyer](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/ffreyer/32/21569_2.png) [@ffreyer](https://discourse.julialang.org/u/ffreyer)\
**Post date:** [August 20, 2025, 2:29pm UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/9 "2025-08-20T14:29:51Z")

</div>

I’m always late to these threads but maybe it’s still worth adding some information…

> [@eldee](#):
>
> I guess the idea (for some reason, it seems quite inefficient) is that we check for ‘all’ values `isoval` between the minimum and maximum value of `volume` whether it lies sufficiently close (according to `iso_eps`) to one of the supplied `levels`. If so, we draw it using `volume!` (the source code of which I fail to locate) in a certain colour.

All `volume` plots do some form of simple ray tracing. For 3D contours a ray steps through the bounding box of the plot, sampling values from the 3D array. These values then sample the colormap using the colorrange to get colors which get blended together. To draw only the requested levels, everything outside of them is transparent in the colormap.

After my pr the colormap density now depends on `isorange` which effectively controls the thickness of isosurfaces. Hopefully it generates enough color density to avoid skipping isosurfaces now. `isorange` is also settable again.

> [@mreichMPI-BGC](#):
>
> The “look and feel” is still different from “real” surfaces I believe, e.g.

I’d argue it’s more realistic. If you look at something like colored glass you will see a darker, more intense color at shallow angles because light passes through more of the material. It’s the same effect here.

> [@mreichMPI-BGC](#):
>
> Also in the rendering of the surface there are strange shading patterns

`transparency = true` should help there. The default `transparency = false` still applies transparency but it’s render order dependent. So it may render all, some or only the front most overlapping triangle.

> [@mreichMPI-BGC](#):
>
> One cannot define vector of alphas (which would be nice because outer surfaces (shells) “should” be more transparent.

I didn’t add this, but it shouldn’t be that difficult to add if anyone wants to try. It should just require getting the level index here and something like `sv_getindex(alpha, level_index)` in the color construction.

> <https://github.com/MakieOrg/Makie.jl/blob/d8f53444bc7d328d351d3265b1380d279eb2058b/Makie/src/basic_recipes/contours.jl#L175-L176>

---

<div class="post-metadata">

**Author:** ![eldee](https://avatars.discourse-cdn.com/v4/letter/e/b5a626/32.png) [@eldee](https://discourse.julialang.org/u/eldee)\
**Post date:** [August 20, 2025, 4:10pm UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/10 "2025-08-20T16:10:43Z")

</div>

> [@ffreyer](#):
>
> All `volume` plots do some form of simple ray tracing. For 3D contours a ray steps through the bounding box of the plot, sampling values from the 3D array. These values then sample the colormap using the colorrange to get colors which get blended together. To draw only the requested levels, everything outside of them is transparent in the colormap.

It still sounds inefficient to me to check uniformly between `minimum(volume)` and `maximum(volume)`. Why not focus on the regions close to `levels`? For a fixed ‘budget’ `N`, I guess it wouldn’t speed up the `volume` rendering, but you would get more detailed information where it matters (as far as I understand).

---

<div class="post-metadata">

**Author:** ![ffreyer](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/ffreyer/32/21569_2.png) [@ffreyer](https://discourse.julialang.org/u/ffreyer)\
**Post date:** [August 21, 2025, 11:15am UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/11 "2025-08-21T11:15:23Z")

</div>

We don’t step through the minimum .. maximum range. It’s more like we step through array indices, e.g.

```julia
total_color = RGBAf(0,0,0,0)
for i in 1:size(volume, 1)
    value = volume[i, 1, 1]
    total_color += get_color(colormap, colorrange, value)
end

```

We can’t really shortcut here because we don’t know anything about the volume data. It could be well behaved data where you can do bisection or Newton’s method or something like that. But it could also be repeating or discontinuous or just generally random/not smooth.

Excluding transparent colors from the colormap would be an option, but then we’d need to do something like

```julia
for level in levels
    if abs(sample - level) < isorange
        r = level_to_colormap_range(colormap, i)
        value_01 = (sample - level - isorange) / (2 * isorange)
        color = get_interpolated_color(view(colormap, r), value_01)
        return color
    end
end

```

instead of just

```julia-auto
get_interpolated_color(colormap, (sample - colorrange[1]) / (colorrange[2] - colorrange[1])

```

We’d likely just get worse performance for a lower memory overhead. Quality would probably be more or less the same because we still work with the same isorange and number of samples.

---

<div class="post-metadata">

**Author:** ![eldee](https://avatars.discourse-cdn.com/v4/letter/e/b5a626/32.png) [@eldee](https://discourse.julialang.org/u/eldee)\
**Post date:** [August 21, 2025, 12:16pm UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/12 "2025-08-21T12:16:09Z")

</div>

Could you link to the `volume`/`volume!` implementation? That would presumably make things clearer. (Also more generally, how would one go about finding such code? Makie seems very difficult to navigate.)

> [@ffreyer](#):
>
> We don’t step through the minimum .. maximum range.

You’re probably referring to `volume!` here, but I was talking about `contour!` (`plot!(::Contour)`):

```julia
valuerange = lift(nan_extrema, plot, volume)
cmap = lift(..., valuerange) do ..., vrange
    map(1:N) do i
        i01 = (i - 1) / (N - 1)
        isoval = vrange[1] + (i01 * (vrange[2] - vrange[1]))
    end
end

```

> [@ffreyer](#):
>
> Excluding transparent colors from the colormap would be an option, but then we’d need to do something like

I think you misunderstood what I was talking about with the non-uniform idea. As an example, suppose you have data in [0, 1] and are looking for the isosurface for value `0.5` using `N = 4`. Then currently the `cmap` could be something like

- [0, 0.33) → transparent
- [0.33, 0.67) → blue
- [0.67, 1] → transparent.

But alternatively, you could use e.g.

- [0, 0.45) → transparent
- [0.45, 0.55) → blue
- [0.55, 1] → transparent.

I would expect this to result in better-defined boundaries, which is what you would want for a contour plot. The volume rendering itself would not change.

---

<div class="post-metadata">

**Author:** ![ffreyer](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/ffreyer/32/21569_2.png) [@ffreyer](https://discourse.julialang.org/u/ffreyer)\
**Post date:** [August 21, 2025, 2:27pm UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/13 "2025-08-21T14:27:43Z")

</div>

3D contours are volume plots. They end up running this shader function

> <https://github.com/MakieOrg/Makie.jl/blob/d8f53444bc7d328d351d3265b1380d279eb2058b/GLMakie/assets/shader/volume.frag#L212-L255>

> [@eldee](#):
>
> ```julia-auto
> valuerange = lift(nan_extrema, plot, volume)
> cmap = lift(..., valuerange) do ..., vrange
> map(1:N) do i
> i01 = (i - 1) / (N - 1)
> isoval = vrange[1] + (i01 * (vrange[2] - vrange[1]))
> end
> end
> 
> ```

That code got updated in 0.24.6

> [@eldee](#):
>
> Then currently the `cmap` could be something like
> 
> - [0, 0.33) → transparent
> - [0.33, 0.67) → blue
> - [0.67, 1] → transparent.
> 
> But alternatively, you could use e.g.
> 
> - [0, 0.45) → transparent
> - [0.45, 0.55) → blue
> - [0.55, 1] → transparent.

That controls the thickness of the isosurface which is set by `isorange`. The attribute was broken from 0.24.0 to 0.24.5 though. In 0.24.6 you can set it again and it has an effect on the generated colormap:

```julia-auto
using Colors, Makie
f,a,p = contour(rand(10,10,10), isorange = 0.1, levels = [0.5])
count(c -> alpha(c) != 0.0, p.computed_colormap[]) # 14 visible colors
length(p.computed_colormap[]) # 100 total

f,a,p = contour(rand(10,10,10), isorange = 0.01, levels = [0.5])
count(c -> alpha(c) != 0.0, p.computed_colormap[]) # 5 visible colors
length(p.computed_colormap[]) # 260 total

```

> [@eldee](#):
>
> I would expect this to result in better-defined boundaries, which is what you would want for a contour plot.

If you’re talking about the fuzziness/blur on the … surface of the surfaces, e.g. the left and right edges here

 ![image](https://global.discourse-cdn.com/julialang/original/3X/1/e/1e783dd7a83a0ef589439bf794ee73bce6362cd4.png)  
then that comes from the shader interpolating between an opaque color and a transparent one. That can sort of be reduced by increasing the total number of colors in the colormap, because then you go through the interpolation faster. If you want to get rid off it completely you’d have to change the shader algorithm though.

---

<div class="post-metadata">

**Author:** ![eldee](https://avatars.discourse-cdn.com/v4/letter/e/b5a626/32.png) [@eldee](https://discourse.julialang.org/u/eldee)\
**Post date:** [August 21, 2025, 6:04pm UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/14 "2025-08-21T18:04:38Z")

</div>

> [@ffreyer](#):
>
> That code got updated in 0.24.6

Indeed, but it still works uniformly, going from the (padded colorrange) minimum and maximum of the volume data:

```julia
map!(
    plot,
    [..., :padded_colorrange, :computed_isorange...],
    :computed_colormap
) do ..., (min, max), isorange, ...
    N = ceil(Int, 2.5 * (max - min) / isorange)
    ...
    return map(1:N) do i
        isoval = min + (i - 1) / (N - 1) * (max - min)
        ...
    end
end

```

Using `isorange` you can control `N` and hence the thickness. But I still feel like it makes more sense to cluster the `isoval`s around the `levels` values (when `levels` is a `Vector`), instead of taking them uniformly. Going back to the example, contrast a uniform ‘`cmap`’ for `N = 10`

- [0,0.11) → transparent
- [0.11, 0.22) → transparent
- [0.22, 0.33) → transparent
- [0.33, 0.44) → transparent
- [0.44, 0.56) → blue
- [0.56, 0.67) → transparent
- [0.67, 0.78) → transparent
- [0.78, 0.89) → transparent
- [0.89, 1.) → transparent

(where 90% of the intervals are transparent, i.e. non-informative), with a non-uniform equivalent with `N = 4`

- [0,0.44) → transparent
- [0.44, 0.56) → blue
- [0.56, 0.1) → transparent

Conversely, I would expect that the non-uniform version for `N = 10`, which splits up the ‘blue’ interval, is somehow more detailed. But I’d need a bit more time to think about how the `cmap` is actually used. Maybe this would only be useful if you decrease the opacity in the ‘blue’ interval farther away from the `levels` values or something like that.

It’s of course also entirely possible that in practice this all does not matter as increasing `N` comes at a negligible cost.

---

<div class="post-metadata">

**Author:** ![ffreyer](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/ffreyer/32/21569_2.png) [@ffreyer](https://discourse.julialang.org/u/ffreyer)\
**Post date:** [August 22, 2025, 2:38pm UTC](https://discourse.julialang.org/t/3d-isosurface-plotting-in-makie/131470/15 "2025-08-22T14:38:11Z")

</div>

Right, this is what I meant with the `get_interpolated_colormap` comments. With this

> [@eldee](#):
>
> - [0,0.11) → transparent
> - [0.11, 0.22) → transparent
> - [0.22, 0.33) → transparent
> - [0.33, 0.44) → transparent
> - [0.44, 0.56) → blue
> - [0.56, 0.67) → transparent
> - [0.67, 0.78) → transparent
> - [0.78, 0.89) → transparent
> - [0.89, 1.) → transparent

you just need to transform values to indices to get the appropriate color. In GLSL you can use a 0..1 value as an index, so it’s just `texture(colormap, (sample - 0) / (1 - 0))`.

> [@eldee](#):
>
> - [0,0.44) → transparent
> - [0.44, 0.56) → blue
> - [0.56, 0.1) → transparent

With this your colormap is `[:transparent, :blue, :transparent]`. To get `:blue` only in the appropriate interval you now need to explicitly check `if (0.44 <= sample < 0.56); return colormap[2]`.

> [@eldee](#):
>
> It’s of course also entirely possible that in practice this all does not matter as increasing `N` comes at a negligible cost.

Yea I think that’s probably the case. The cost of making a 1000 element array in Julia isn’t particularly large and it’s probably not something that updates frequently either. The GPU upload only happens when the data changes and the up-front cost of uploading something is quite significant afaik. So adding a couple KB might not matter much.
