Render: support pixel density in the render pipeline

Add a "Pixel Density" sub-panel to render output settings which
can be used to set the density (as pixels per inch for example).

This is then written to images that support pixel density.

Details:

- The scene has two values a PPM factor and a and base unit.
- The base unit defaults to pixels per inch as this is the most
  common unit used.
- Unit presets for pixels per inch/centimeter/meter are included.
- The pixel density is stored in the render result & EXR cache.
- For non 1:1 aspect renders, the density increases on the axis
  which looks "stretched", so the PPM will print the correct
  aspect with non-square pixels.

Ref !127831
This commit is contained in:
Campbell Barton
2025-04-05 08:49:22 +00:00
parent e7c9ea3b27
commit af1110fb3c
21 changed files with 281 additions and 6 deletions

View File

@@ -0,0 +1 @@
import bpy

View File

@@ -0,0 +1,2 @@
import bpy
bpy.context.scene.render.ppm_base = 0.01

View File

@@ -0,0 +1,2 @@
import bpy
bpy.context.scene.render.ppm_base = 0.0254

View File

@@ -0,0 +1,2 @@
import bpy
bpy.context.scene.render.ppm_base = 1.0

View File

@@ -32,6 +32,13 @@ class RENDER_MT_framerate_presets(Menu):
draw = Menu.draw_preset
class RENDER_MT_pixeldensity_presets(Menu):
bl_label = "Pixel Density Presets"
preset_subdir = "pixel_density"
preset_operator = "script.execute_preset"
draw = Menu.draw_preset
class RenderOutputButtonsPanel:
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
@@ -375,6 +382,83 @@ class RENDER_PT_output_color_management(RenderOutputButtonsPanel, Panel):
col.template_colormanaged_view_settings(owner, "view_settings")
class RENDER_PT_output_pixel_density(RenderOutputButtonsPanel, Panel):
bl_label = "Pixel Density"
bl_options = {'DEFAULT_CLOSED'}
bl_parent_id = "RENDER_PT_output"
COMPAT_ENGINES = {
'BLENDER_RENDER',
'BLENDER_EEVEE',
'BLENDER_EEVEE_NEXT',
'BLENDER_WORKBENCH',
}
_pixel_density_args_prev = None
_preset_class = None
@staticmethod
def _draw_pixeldensity_label(*args):
# Avoids re-creating text string each draw.
if RENDER_PT_output_pixel_density._pixel_density_args_prev == args:
return RENDER_PT_output_pixel_density._pixel_density_ret
ppm_base, preset_label = args
# NOTE: `as_float_32` is needed because Blender stores this value as a 32bit float.
# Which won't match Python's 64bit float.
def as_float_32(f):
from struct import pack, unpack
return unpack("f", pack("f", f))[0]
# NOTE: Values here are duplicated from presets, ideally this could be avoided.
unit_name = {
as_float_32(0.0254): iface_("Inch"),
as_float_32(0.01): iface_("Centimeter"),
as_float_32(1.0): iface_("Meter"),
}.get(ppm_base)
if unit_name is None:
pixeldensity_label_text = iface_("Custom")
show_pixeldensity = True
else:
pixeldensity_label_text = iface_("Pixels/{:s}").format(unit_name)
show_pixeldensity = (preset_label == "Custom")
RENDER_PT_output_pixel_density._pixel_density_args_prev = args
RENDER_PT_output_pixel_density._pixel_density_ret = args = (pixeldensity_label_text, show_pixeldensity)
return args
@staticmethod
def draw_pixeldensity(layout, rd):
if RENDER_PT_output_pixel_density._preset_class is None:
RENDER_PT_output_pixel_density._preset_class = bpy.types.RENDER_MT_pixeldensity_presets
args = rd.ppm_base, RENDER_PT_output_pixel_density._preset_class.bl_label
pixeldensity_label_text, show_pixeldensity = RENDER_PT_output_pixel_density._draw_pixeldensity_label(*args)
layout.prop(rd, "ppm_factor", text="Pixels")
row = layout.split(factor=0.4)
row.alignment = 'RIGHT'
row.label(text="Unit")
row.menu("RENDER_MT_pixeldensity_presets", text=pixeldensity_label_text)
if show_pixeldensity:
col = layout.column(align=True)
col.prop(rd, "ppm_base", text="Base")
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
scene = context.scene
rd = scene.render
self.draw_pixeldensity(layout, rd)
class RENDER_PT_encoding(RenderOutputButtonsPanel, Panel):
bl_label = "Encoding"
bl_parent_id = "RENDER_PT_output"
@@ -603,6 +687,7 @@ classes = (
RENDER_PT_format_presets,
RENDER_PT_ffmpeg_presets,
RENDER_MT_framerate_presets,
RENDER_MT_pixeldensity_presets,
RENDER_PT_format,
RENDER_PT_frame_range,
RENDER_PT_time_stretching,
@@ -610,6 +695,7 @@ classes = (
RENDER_PT_output,
RENDER_PT_output_views,
RENDER_PT_output_color_management,
RENDER_PT_output_pixel_density,
RENDER_PT_encoding,
RENDER_PT_encoding_video,
RENDER_PT_encoding_audio,

View File

@@ -282,6 +282,10 @@ void BKE_scene_multiview_view_prefix_get(Scene *scene,
void BKE_scene_multiview_videos_dimensions_get(
const RenderData *rd, size_t width, size_t height, size_t *r_width, size_t *r_height);
int BKE_scene_multiview_num_videos_get(const RenderData *rd);
/**
* Calculate the final pixels-per-meter, from the scenes PPM & aspect data.
*/
void BKE_scene_ppm_get(const RenderData *rd, double r_ppm[2]);
/* depsgraph */
void BKE_scene_allocate_depsgraph_hash(Scene *scene);

View File

@@ -4158,6 +4158,7 @@ static ImBuf *image_load_sequence_multilayer(Image *ima, ImageUser *iuser, int e
IMB_refImBuf(ibuf);
BKE_imbuf_stamp_info(ima->rr, ibuf);
copy_v2_v2_db(ibuf->ppm, ima->rr->ppm);
image_init_after_load(ima, iuser, ibuf);
image_assign_ibuf(ima, ibuf, iuser ? iuser->multi_index : 0, entry);
@@ -4458,6 +4459,7 @@ static ImBuf *image_get_ibuf_multilayer(Image *ima, ImageUser *iuser)
image_init_after_load(ima, iuser, ibuf);
BKE_imbuf_stamp_info(ima->rr, ibuf);
copy_v2_v2_db(ibuf->ppm, ima->rr->ppm);
image_assign_ibuf(ima, ibuf, iuser ? iuser->multi_index : IMA_NO_INDEX, 0);
}

View File

@@ -377,11 +377,35 @@ static bool image_save_single(ReportList *reports,
BKE_imbuf_stamp_info(rr, ibuf);
}
/* Don't write permanently into the render-result. */
double rr_ppm_prev[2] = {0, 0};
if (save_as_render && rr) {
/* These could be used in the case of a null `rr`, currently they're not though.
* Note that setting zero when there is no `rr` is intentional,
* this signifies no valid PPM is set. */
double ppm[2] = {0, 0};
if (opts->scene) {
BKE_scene_ppm_get(&opts->scene->r, ppm);
}
copy_v2_v2_db(rr_ppm_prev, rr->ppm);
copy_v2_v2_db(rr->ppm, ppm);
}
/* From now on, calls to #BKE_image_release_renderresult must restore the PPM beforehand. */
auto render_result_restore_ppm = [rr, save_as_render, rr_ppm_prev]() {
if (save_as_render && rr) {
copy_v2_v2_db(rr->ppm, rr_ppm_prev);
}
};
/* fancy multiview OpenEXR */
if (imf->views_format == R_IMF_VIEWS_MULTIVIEW && is_exr_rr) {
/* save render result */
ok = BKE_image_render_write_exr(
reports, rr, opts->filepath, imf, save_as_render, nullptr, layer);
render_result_restore_ppm();
BKE_image_release_renderresult(opts->scene, ima, rr);
image_save_post(reports, ima, ibuf, ok, opts, true, opts->filepath, r_colorspace_changed);
BKE_image_release_ibuf(ima, ibuf, lock);
@@ -397,6 +421,8 @@ static bool image_save_single(ReportList *reports,
ok = BKE_imbuf_write_as(colormanaged_ibuf, opts->filepath, imf, save_copy);
imbuf_save_post(ibuf, colormanaged_ibuf);
}
render_result_restore_ppm();
BKE_image_release_renderresult(opts->scene, ima, rr);
image_save_post(reports,
ima,
@@ -465,6 +491,7 @@ static bool image_save_single(ReportList *reports,
ok &= ok_view;
}
render_result_restore_ppm();
BKE_image_release_renderresult(opts->scene, ima, rr);
if (is_exr_rr) {
@@ -476,6 +503,8 @@ static bool image_save_single(ReportList *reports,
if (imf->imtype == R_IMF_IMTYPE_MULTILAYER) {
ok = BKE_image_render_write_exr(
reports, rr, opts->filepath, imf, save_as_render, nullptr, layer);
render_result_restore_ppm();
BKE_image_release_renderresult(opts->scene, ima, rr);
image_save_post(reports, ima, ibuf, ok, opts, true, opts->filepath, r_colorspace_changed);
BKE_image_release_ibuf(ima, ibuf, lock);
@@ -552,10 +581,12 @@ static bool image_save_single(ReportList *reports,
IMB_freeImBuf(ibuf_stereo[i]);
}
render_result_restore_ppm();
BKE_image_release_renderresult(opts->scene, ima, rr);
}
}
else {
render_result_restore_ppm();
BKE_image_release_renderresult(opts->scene, ima, rr);
BKE_image_release_ibuf(ima, ibuf, lock);
}
@@ -967,7 +998,7 @@ bool BKE_image_render_write_exr(ReportList *reports,
const int compress = (imf ? imf->exr_codec : 0);
const int quality = (imf ? imf->quality : 90);
bool success = IMB_exr_begin_write(
exrhandle, filepath, rr->rectx, rr->recty, compress, quality, rr->stamp_data);
exrhandle, filepath, rr->rectx, rr->recty, rr->ppm, compress, quality, rr->stamp_data);
if (success) {
IMB_exr_write_channels(exrhandle);
}

View File

@@ -43,6 +43,7 @@
#include "DNA_world_types.h"
#include "BLI_listbase.h"
#include "BLI_math_base.h"
#include "BLI_math_rotation.h"
#include "BLI_path_utils.hh"
#include "BLI_string.h"
@@ -3188,6 +3189,29 @@ int BKE_scene_multiview_num_videos_get(const RenderData *rd)
return BKE_scene_multiview_num_views_get(rd);
}
void BKE_scene_ppm_get(const RenderData *rd, double r_ppm[2])
{
/* Should not be zero, prevent divide by zero if it is. */
if (UNLIKELY(rd->ppm_base == 0.0f)) {
/* Zero PPM should be ignored. */
r_ppm[0] = 0.0;
r_ppm[1] = 0.0;
}
double xasp = 1.0, yasp = 1.0;
if (rd->xasp < rd->yasp) {
yasp = double(rd->yasp) / double(rd->xasp);
}
else if (rd->xasp > rd->yasp) {
xasp = double(rd->xasp) / double(rd->yasp);
}
const double ppm_base = rd->ppm_base;
const double ppm_factor = rd->ppm_factor;
r_ppm[0] = (ppm_factor / ppm_base) * xasp;
r_ppm[1] = (ppm_factor / ppm_base) * yasp;
}
/* Manipulation of depsgraph storage. */
/* This is a key which identifies depsgraph. */

View File

@@ -6693,6 +6693,16 @@ void blo_do_versions_400(FileData *fd, Library * /*lib*/, Main *bmain)
rename_mesh_uv_seam_attribute(*mesh);
}
/* TODO: define version bump. */
{
LISTBASE_FOREACH (Scene *, scene, &bmain->scenes) {
if (scene->r.ppm_factor == 0.0f && scene->r.ppm_base == 0.0f) {
scene->r.ppm_factor = 72.0f;
scene->r.ppm_base = 0.0254f;
}
}
}
/**
* Always bump subversion in BKE_blender_version.h when adding versioning
* code here, and wrap it inside a MAIN_VERSION_FILE_ATLEAST check.

View File

@@ -8,6 +8,7 @@
#include "BLI_assert.h"
#include "BLI_listbase.h"
#include "BLI_map.hh"
#include "BLI_math_vector.h"
#include "BLI_math_vector_types.hh"
#include "BLI_string.h"
#include "BLI_utildefines.h"
@@ -23,6 +24,7 @@
#include "BKE_image.hh"
#include "BKE_image_save.hh"
#include "BKE_report.hh"
#include "BKE_scene.hh"
#include "RE_pipeline.h"
@@ -45,6 +47,12 @@ FileOutput::FileOutput(const std::string &path,
render_result_->rectx = size.x;
render_result_->recty = size.y;
/* NOTE: set dummy values which will won't be used unless overwritten.
* When `save_as_render` is set, this is overwritten by the scenes PPM setting.
* We *could* support setting the DPI in the file output node too. */
render_result_->ppm[0] = 0.0;
render_result_->ppm[1] = 0.0;
/* File outputs are always single layer, as images are actually stored in passes on that single
* layer. Create a single unnamed layer to add the passes to. A single unnamed layer is treated
* by the EXR writer as a special case where the channel names take the form:
@@ -108,6 +116,7 @@ void FileOutput::add_pass(const char *pass_name,
render_pass->ibuf = IMB_allocImBuf(
render_result_->rectx, render_result_->recty, channels_count * 8, 0);
render_pass->ibuf->channels = channels_count;
copy_v2_v2_db(render_pass->ibuf->ppm, render_result_->ppm);
IMB_assign_float_buffer(render_pass->ibuf, buffer, IB_TAKE_OWNERSHIP);
}
@@ -127,6 +136,12 @@ void FileOutput::save(Scene *scene)
BKE_render_result_stamp_data(render_result_, field.key.c_str(), field.value.c_str());
}
/* NOTE: without this the file will be written without any density information.
* So always write this. */
if (save_as_render_ || true) {
BKE_scene_ppm_get(&scene->r, render_result_->ppm);
}
BKE_image_render_write(
&reports, render_result_, scene, true, path_.c_str(), &format_, save_as_render_);

View File

@@ -49,6 +49,7 @@ bool IMB_exr_begin_write(void *handle,
const char *filepath,
int width,
int height,
const double ppm[2],
int compress,
int quality,
const StampData *stamp);
@@ -85,3 +86,5 @@ void IMB_exr_close(void *handle);
void IMB_exr_add_view(void *handle, const char *name);
bool IMB_exr_has_multilayer(void *handle);
bool IMB_exr_get_ppm(void *handle, double ppm[2]);

View File

@@ -961,6 +961,7 @@ bool IMB_exr_begin_write(void *handle,
const char *filepath,
int width,
int height,
const double ppm[2],
int compress,
int quality,
const StampData *stamp)
@@ -993,6 +994,11 @@ bool IMB_exr_begin_write(void *handle,
addMultiView(header, *data->multiView);
}
if (ppm[0] != 0.0 && ppm[1] != 0.0) {
addXDensity(header, ppm[0] * 0.0254);
header.pixelAspectRatio() = blender::math::safe_divide(ppm[1], ppm[0]);
}
/* avoid crash/abort when we don't have permission to write here */
/* manually create ofstream, so we can handle utf-8 filepaths on windows */
try {
@@ -2036,6 +2042,23 @@ static void imb_exr_set_known_colorspace(const Header &header, ImFileColorSpace
}
}
static bool exr_get_ppm(MultiPartInputFile &file, double ppm[2])
{
const Header &header = file.header(0);
if (!hasXDensity(header)) {
return false;
}
ppm[0] = double(xDensity(header)) / 0.0254;
ppm[1] = ppm[0] * double(header.pixelAspectRatio());
return true;
}
bool IMB_exr_get_ppm(void *handle, double ppm[2])
{
ExrHandle *data = (ExrHandle *)handle;
return exr_get_ppm(*data->ifile, ppm);
}
ImBuf *imb_load_openexr(const uchar *mem, size_t size, int flags, ImFileColorSpace &r_colorspace)
{
ImBuf *ibuf = nullptr;
@@ -2077,11 +2100,7 @@ ImBuf *imb_load_openexr(const uchar *mem, size_t size, int flags, ImFileColorSpa
ibuf->foptions.flag |= exr_is_half_float(*file) ? OPENEXR_HALF : 0;
ibuf->foptions.flag |= openexr_header_get_compression(file_header);
if (hasXDensity(file_header)) {
/* Convert inches to meters. */
ibuf->ppm[0] = double(xDensity(file_header)) / 0.0254;
ibuf->ppm[1] = ibuf->ppm[0] * double(file_header.pixelAspectRatio());
}
exr_get_ppm(*file, ibuf->ppm);
imb_exr_set_known_colorspace(file_header, r_colorspace);

View File

@@ -39,6 +39,7 @@ bool IMB_exr_begin_write(void * /*handle*/,
const char * /*filepath*/,
int /*width*/,
int /*height*/,
const double /*ppm*/[2],
int /*compress*/,
int /*quality*/,
const StampData * /*stamp*/)
@@ -80,3 +81,8 @@ bool IMB_exr_has_multilayer(void * /*handle*/)
{
return false;
}
bool IMB_exr_get_ppm(void * /*handle*/, double /*ppm*/[2])
{
return false;
}

View File

@@ -66,6 +66,8 @@
.ysch = 1080, \
.xasp = 1, \
.yasp = 1, \
.ppm_factor = 72.0f, \
.ppm_base = 0.0254f, \
.tilex = 256, \
.tiley = 256, \
.size = 100, \

View File

@@ -736,6 +736,29 @@ typedef struct RenderData {
*/
float xasp, yasp;
/**
* Pixels per meter (factor of PPM base).
* The final calculated PPM is stored as a pair of doubles,
* taking the render aspect into support separate X/Y density.
* Editing the final PPM directly isn't practical as common DPI
* values often result the fractional part having many decimal places.
* So expose the factor & base, where the base is used to set the "preset" in the GUI,
* (Inch CM, MM... etc).
*
* Once calculated the final PPM is stored in the #ImBuf & #RenderResult
* which are saved/loaded through #ImBuf API's or multi-layer EXR images
* in the case of the render-result.
*
* Note that storing the X/Y density means it's possible know the aspect
* used to render the image which may be useful.
*/
float ppm_factor;
/**
* Pixels per meter base (0.0254 for DPI), a multiplier for `ppm_factor`.
* Used to implement "presets".
*/
float ppm_base;
float frs_sec_base;
/**

View File

@@ -6922,6 +6922,22 @@ static void rna_def_scene_render_data(BlenderRNA *brna)
prop, "Pixel Aspect Y", "Vertical aspect ratio - for anamorphic or non-square pixel output");
RNA_def_property_update(prop, NC_SCENE | ND_RENDER_OPTIONS, "rna_SceneCamera_update");
/* Pixels per meters (also DPI). */
prop = RNA_def_property(srna, "ppm_factor", PROP_FLOAT, PROP_NONE);
RNA_def_property_float_sdna(prop, nullptr, "ppm_factor");
RNA_def_property_clear_flag(prop, PROP_ANIMATABLE);
RNA_def_property_range(prop, 1e-5f, 1e6f);
RNA_def_property_ui_range(prop, 0.0001f, 10000.0f, 2, 2);
RNA_def_property_ui_text(prop, "PPM Factor", "The unit multiplier for pixels per meter");
prop = RNA_def_property(srna, "ppm_base", PROP_FLOAT, PROP_NONE);
RNA_def_property_float_sdna(prop, nullptr, "ppm_base");
RNA_def_property_clear_flag(prop, PROP_ANIMATABLE);
RNA_def_property_range(prop, 1e-5f, 1e6f);
/* Important to show at least 3 decimal points because multiple presets set this to 1.001. */
RNA_def_property_ui_range(prop, 0.0001f, 10000.0f, 2, 4);
RNA_def_property_ui_text(prop, "PPM Base", "The unit multiplier for pixels per meter");
prop = RNA_def_property(srna, "ffmpeg", PROP_POINTER, PROP_NONE);
RNA_def_property_struct_type(prop, "FFmpegSettings");
RNA_def_property_pointer_sdna(prop, nullptr, "ffcodecdata");

View File

@@ -128,6 +128,14 @@ struct RenderResult {
/* for render results in Image, verify validity for sequences */
int framenr;
/**
* Pixels per meter (for image output).
* - Typically initialized via #BKE_scene_ppm_get.
* - May be zero which indicates the PPM being "unset".
* Although in most cases a scene is available.
*/
double ppm[2];
/* for acquire image, to indicate if it there is a combined layer */
bool have_combined;

View File

@@ -200,6 +200,8 @@ static RenderResult *render_result_from_bake(
rr->tilerect.xmax = x + w;
rr->tilerect.ymax = y + h;
BKE_scene_ppm_get(&engine->re->r, rr->ppm);
/* Add single baking render layer. */
RenderLayer *rl = MEM_callocN<RenderLayer>("bake render layer");
STRNCPY(rl->name, layername);

View File

@@ -398,6 +398,8 @@ void RE_AcquireResultImageViews(Render *re, RenderResult *rr)
rr->rectx = re->result->rectx;
rr->recty = re->result->recty;
copy_v2_v2_db(rr->ppm, re->result->ppm);
/* creates a temporary duplication of views */
render_result_views_shallowcopy(rr, re->result);
@@ -450,6 +452,8 @@ void RE_AcquireResultImage(Render *re, RenderResult *rr, const int view_id)
rr->rectx = re->result->rectx;
rr->recty = re->result->recty;
copy_v2_v2_db(rr->ppm, re->result->ppm);
/* `scene.rd.actview` view. */
rv = RE_RenderViewGetById(re->result, view_id);
rr->have_combined = (rv->ibuf != nullptr);
@@ -921,6 +925,7 @@ void RE_InitState(Render *re,
re->result = MEM_callocN<RenderResult>("new render result");
re->result->rectx = re->rectx;
re->result->recty = re->recty;
BKE_scene_ppm_get(&re->r, re->result->ppm);
render_result_view_new(re->result, "");
}

View File

@@ -210,6 +210,7 @@ static void render_layer_allocate_pass(RenderResult *rr, RenderPass *rp)
rp->ibuf = IMB_allocImBuf(rr->rectx, rr->recty, get_num_planes_for_pass_ibuf(*rp), 0);
rp->ibuf->channels = rp->channels;
copy_v2_v2_db(rp->ibuf->ppm, rr->ppm);
IMB_assign_float_buffer(rp->ibuf, buffer_data, IB_TAKE_OWNERSHIP);
assign_render_pass_ibuf_colorspace(*rp);
@@ -291,6 +292,8 @@ RenderResult *render_result_new(Render *re,
rr->rectx = rectx;
rr->recty = recty;
BKE_scene_ppm_get(&re->r, rr->ppm);
/* tilerect is relative coordinates within render disprect. do not subtract crop yet */
rr->tilerect.xmin = partrct->xmin - re->disprect.xmin;
rr->tilerect.xmax = partrct->xmax - re->disprect.xmin;
@@ -715,6 +718,8 @@ RenderResult *render_result_new_from_exr(
rr->rectx = rectx;
rr->recty = recty;
IMB_exr_get_ppm(exrhandle, rr->ppm);
IMB_exr_multilayer_convert(exrhandle, rr, ml_addview_cb, ml_addlayer_cb, ml_addpass_cb);
LISTBASE_FOREACH (RenderLayer *, rl, &rr->layers) {
@@ -727,6 +732,8 @@ RenderResult *render_result_new_from_exr(
rpass->rectx = rectx;
rpass->recty = recty;
copy_v2_v2_db(rpass->ibuf->ppm, rr->ppm);
if (RE_RenderPassIsColor(rpass)) {
IMB_colormanagement_transform_float(rpass->ibuf->float_buffer.data,
rpass->rectx,
@@ -1104,6 +1111,8 @@ ImBuf *RE_render_result_rect_to_ibuf(RenderResult *rr,
/* float factor for random dither, imbuf takes care of it */
ibuf->dither = dither;
copy_v2_v2_db(ibuf->ppm, rr->ppm);
/* prepare to gamma correct to sRGB color space
* note that sequence editor can generate 8bpc render buffers
*/
@@ -1343,6 +1352,9 @@ RenderResult *RE_DuplicateRenderResult(RenderResult *rr)
new_rr->ibuf = IMB_dupImBuf(rr->ibuf);
new_rr->stamp_data = BKE_stamp_data_copy(new_rr->stamp_data);
copy_v2_v2_db(new_rr->ppm, rr->ppm);
return new_rr;
}