前言

本文仅作为个人笔记分享,又因为本章涉及多个专业领域而本人皆未接触过,所以难免出错,请各位读者注意。

  1. 对于数字图像需要区分image pixels(特定采样处的函数值)和display pixels(显示器显示值)。
  2. 收集采样值,并将其转化为连续函数的过程被称为重构。
  3. 为了计算在数字图像上的离散像素值,我们需要在原始的图片函数上不断的采样。
  4. 采样与重构的过程中涉及到近似运算,而这个过程中产生的错误被称为锯齿。其产生原因为计算机无法连续对场景进行采样。

可以理解为把真实世界所得图像(人眼)当做一个连续的离散函数,而计算机通过像素显示,为一个不连续的点集。渲染就是一个不断采样,将结果累加缩小误差,并且通过最终值将这个函数重构的过程。(图7.1很好地说明了这些)

傅里叶分析

傅里叶分析可以分析重构函数与原始函数之间的质量。

推荐先去看一下知乎的教程,先对傅里叶变换有一定了解,之后再去看@秦春林 编写的《全局光照技术》(该书提炼PBRT中的内容,而且作者用自己的话将其描述清楚),之后再去看PBRT,就简单多了。(本人十分有幸听了他的讲座,感觉对阅读之后PBRT的章节十分有帮助)

傅里叶分析之掐死教程(完整版)更新于2014.06.06

https://zhuanlan.zhihu.com/p/19763358

傅里叶变换:任意非周期的(但该曲线下的面积是有限的)连续函数可以用正弦和乘以一个加权函数的积分来表示。

傅里叶变换可以理解为讲一个函数由时间域或空间域转化到其频域。

摘自《全局光照技术》

欧拉公式:

$e^{i\pi} =cosx+isinx $

卷积定理:从《全局光照技术》中可知,两个函数的卷积计算因为需要遍历其中一个函数的定义域,所以相当耗时,但是如果拥有频率域的乘积再使用傅里叶反变换就可以得到空间域的卷积。

冲激函数:使用冲激函数的采样特质可以简单得到冲击位置的函数值。

冲激函数的傅里叶变换依然是一个冲激函数。

采样率大于或等于2倍的最大(函数)频率,该离散函数才能够被完美复原。而不能够被完美复原的情况被称为走样。

可惜本人因为专业的关系,没有在大学中上过《信号与系统》,所以不能很好地理解这一章相关内容,只能了解个大概。

图像采样接口

采样器的本质就是在[0,1)范围中生成一堆N维样本。最简单的采样器就是返回一个在[0,1)范围均匀分布随机数。

因为采样的区间必须严格小于1,所以定义一个值为当前浮点类型的1减最小值的变量。这里的代码与书中不太一样,但是道理是一样的。定义在rng.h中。

// Random Number Declarations
#ifndef PBRT_HAVE_HEX_FP_CONSTANTS
static const double DoubleOneMinusEpsilon = 0.99999999999999989;
static const float FloatOneMinusEpsilon = 0.99999994;
#else
static const double DoubleOneMinusEpsilon = 0x1.fffffffffffffp-1;
static const float FloatOneMinusEpsilon = 0x1.fffffep-1;
#endif #ifdef PBRT_FLOAT_IS_DOUBLE
static const Float OneMinusEpsilon = DoubleOneMinusEpsilon;
#else
static const Float OneMinusEpsilon = FloatOneMinusEpsilon;
#endif

傅里叶分析给予我们一种估算2维图像采样质量的方式。我们只需要在均匀采样间隔方面改进。

但考虑到图像边缘的无线频率内容,以及蒙特卡洛光传输算法需要(n>2)维样本,仅靠傅里叶分析是不够的。

数学家们还提出了一种名为“差异”的概念。用于评估采样的N维样本(位置)分布的质量。良好的分布模式的差异值较低。所以生成采样点模式的问题可以理解为寻找低差异(分布)点的问题。

差异的基本思想是估算N维定义域为[0,1)的点集,计算每个区域点的数量,之后比较内部每个区域。为了计算一组点的差异,我们会选择其中一个子集并命名为B,然后计算

(B中点的数量)/采样点总的数量-B的体积

基类Sampler的实现

PS.这段东西纯属我的个人笔记,还是建议直接看代码,有基础的人一下子就懂。

samples1DArraySizes和samples2DArraySizes负责记录采样数组大小。

生成的采样数组会被存储在sampleArray1D和sampleArray2D中,array1DOffset与array2DOffset用于存储采样偏移。

当开始渲染当前某一个像素时,PBRT会传入像素UV信息并调用StartPixel()函数。StartPixel会记录当前采样像素的UV坐标,并且将当前像素采样器索引清零。一些采样器会根据这些信息调整采样分布,而别的采样器则会忽略。

其中Get1D返回下一个采样值(1维)。Get2D返回下一个采样值(2维)。

StartNextSample开启下一轮采样,重置Sampler中的采样偏移值,同时该方法会不断地返回true直到样本数达到需求数。

//在渲染主循环中大致会这样
sampler->StartPixel(p);
do {
Float v = a(sampler->Get1D());
if (v > 0)
v += b(sampler->Get1D());
v += c(sampler->Get1D());
} while (sampler->StartNextSample());

一些采样算法会使用数组来存储样本,而这种方法必须要在渲染前生成样本,在PBRT中的对应函数为:Request1DArray与Request2DArray。

RoundCount()返回采样器所能提供的样本量,此为虚函数,默认直接返回形参n。

Get1DArray、Get2DArray函数返回指向对应维度样本数组的指针。并且检查数组是否有预期大小。同时也会更新采样偏移值。

Sampler类存储了当前各种工作状态,比如采样哪个像素等等。但考虑到多线程安全问题,会给每个渲染线程分配一份采样器,所以PBRT使用Clone虚函数,创建各种采样器的实例。

PixelSampler

针对像素的采样器,在渲染前会同时生成所有维度的采样样本,后面的分层采样会说明。

std::vector<std::vector<Float>> samples1D与std::vector<std::vector<Point2f>> samples2D存储,与此同时还存储没维度信息在current1DDimension与current2DDimension

其他的采样器则继承自GlobalSampler。

分层采样

将采样区域分为成n*n个不重叠的区域,之后在每个区域中各自获取采样点(获取随机点)。采样效果比直接获取要好,不容易出现因为使用平均分层时而出现的锯齿。类名为:StratifiedSampler,继承自PixelSampler。

在高维度的情况下,分层采样的样本量会大大增加。为了解决这个问题,我们可以在部分维度减少采样量或者不进行分层而直接采样。

图7.16解释了这个方法,图中分为x,y(位置)、t(时间)、u,v(屏幕)一共5个维度,但是这5个维度并没有直接关联在一起,而只是与各自的相关维度关联,生成样本。

StratifiedSampler在开始采样之前必须生成1维与2维数据,包括了单个分层采样与数组分层采样。(在StartPixel函数中)以下是生成分层采样样本的代码:

//jitter的作用为切换均匀采样与抖动采样
void StratifiedSample1D(Float *samp, int nSamples, RNG &rng, bool jitter) {
Float invNSamples = (Float)1 / nSamples;
for (int i = 0; i < nSamples; ++i) {
Float delta = jitter ? rng.UniformFloat() : 0.5f;
samp[i] = std::min((i + delta) * invNSamples, OneMinusEpsilon);
}
} void StratifiedSample2D(Point2f *samp, int nx, int ny, RNG &rng, bool jitter) {
Float dx = (Float)1 / nx, dy = (Float)1 / ny;
for (int y = 0; y < ny; ++y)
for (int x = 0; x < nx; ++x) {
Float jx = jitter ? rng.UniformFloat() : 0.5f;
Float jy = jitter ? rng.UniformFloat() : 0.5f;
samp->x = std::min((x + jx) * dx, OneMinusEpsilon);
samp->y = std::min((y + jy) * dy, OneMinusEpsilon);
++samp;
}
}
//之后会调用这个函数打乱每个维度的结果
void Shuffle(T *samp, int count, int nDimensions, RNG &rng) {
for (int i = 0; i < count; ++i) {
int other = i + rng.UniformUInt32(count - i);
for (int j = 0; j < nDimensions; ++j)
std::swap(samp[nDimensions * i + j], samp[nDimensions * other + j]);
}
}
std::unique_ptr<Sampler> StratifiedSampler::Clone(int seed) {
//使用拷贝构造函数
StratifiedSampler *ss = new StratifiedSampler(*this);
//设置rng种子
ss->rng.SetSequence(seed);
//返回智能指针
return std::unique_ptr<Sampler>(ss);
}

使用数组存储样本的采样器会遇到两个问题:

  1. 采样点在2d空间中具有良好分布。
  2. 在图片中临近的采样样本不要过于相近。

分层采样只能解决第一个问题,第二个问题只能靠别的采样器解决。

因为使用者会使用一个值随机作为该图片的采样值,对于多维的分层采样,其样本分布质量不能保证。为了解决这个问题,PBRT使用了名为Latin

hypercube sampling (LHS)的方法(又被称为n-rocks采样)。LHS会均匀按轴向方向地切割每个维度,并且在每个被切割的区域中产生抖动样本。图7.20演示了这个过程。之后这些样本被随机打乱,以此生成良好的分布模式。图7.21显示了最坏的情况。

void LatinHypercube(Float *samples, int nSamples, int nDim, RNG &rng) {
Float invNSamples = (Float)1 / nSamples;
for (int i = 0; i < nSamples; ++i)
for (int j = 0; j < nDim; ++j) {
Float sj = (i + (rng.UniformFloat())) * invNSamples;
//因为结构体的变量存储是紧挨在一起的,所以可以这么操作
samples[nDim * i + j] = std::min(sj, OneMinusEpsilon);
} //对样本进行打乱,同一样本不同维度的数据的打乱相互独立
//Shuffle函数会将该样本其他维度的数据也一起移动
for (int i = 0; i < nDim; ++i) {
for (int j = 0; j < nSamples; ++j) {
int other = j + rng.UniformUInt32(nSamples - j);
std::swap(samples[nDim * j + i], samples[nDim * other + i]);
}
}
}

HAMMERSLEY 与 HALTON 序列

HAMMERSLEY与HALTON序列是两种关系紧密的低差异点集。两者都使用名为“radical inverse”的方法。

推荐看文刀秋二的这篇文章:

https://zhuanlan.zhihu.com/p/20197323?columnSlug=graphics

Van der Corput序列:将一个正整数转化为以b(正整数)进制数之后,之后使用一个矩阵C进行镜像操作,如果C为单位矩阵,那这个操作就相当于以0为镜像轴线,将整数部分的数镜像到小数部分,从而得到一个新的数。

Halton:$X_i:=(\Phi_{b_1}(i),...,\Phi_{b_n}(i))$

既是每一个维度都是一个基于不同底数$b_n$的Van der Corput序列,其中$b_1$...$b_n$互为质数(例如第1到第n个质数)。

Hammersley点集:$X_i:=(\frac{i}{N},\Phi_{b_1}(i),...,\Phi_{b_{n-1}}(i))$

唯一不同的就是将第一个维度变成$\frac{i}{N}$,其中i为样本点的索引,N为样本点集中点的个数。

Hammersley点集只能生成固定数目个样本,而Halton序列则可以生成无穷个样本(当然在计算机里我们只有有限的bit去表示有限个样本点)

//反转一个二进制数
inline uint32_t ReverseBits32(uint32_t n) {
n = (n << 16) | (n >> 16); //十六个数一对,交换相邻两对
n = ((n & 0x00ff00ff) << 8) | ((n & 0xff00ff00) >> 8);//八个数一对,交换相邻两对
n = ((n & 0x0f0f0f0f) << 4) | ((n & 0xf0f0f0f0) >> 4);//四个数一对,交换相邻两对
n = ((n & 0x33333333) << 2) | ((n & 0xcccccccc) >> 2);//两两一对,交换相邻两对
n = ((n & 0x55555555) << 1) | ((n & 0xaaaaaaaa) >> 1);//交换相邻两个值
return n;
} inline uint64_t ReverseBits64(uint64_t n) {
uint64_t n0 = ReverseBits32((uint32_t)n);
uint64_t n1 = ReverseBits32((uint32_t)(n >> 32));
return (n0 << 32) | n1;
} PBRT_NOINLINE static Float RadicalInverseSpecialized(uint64_t a) {
const Float invBase = (Float)1 / (Float)base;
uint64_t reversedDigits = 0;
Float invBaseN = 1;
while (a) {
uint64_t next = a / base;
uint64_t digit = a - next * base;
reversedDigits = reversedDigits * base + digit;
invBaseN *= invBase;
a = next;
}
//容错处理
DCHECK_LT(reversedDigits * invBaseN, 1.00001);
return std::min(reversedDigits * invBaseN, OneMinusEpsilon);
}

使用Scrambling来解决以上两种采样在底数较大时,样本过于规则的问题。

RadicalInverse的实现的效率依赖于一个循环,将索引Index的数字左右颠倒。这一步骤可以通过一次将多个连续数字的左右颠倒连同Faure Scrambling预计算出来,存在一个查找表里。运行的时候直接将索引的多个数字提取出来,然后直接查表得到结果。下面给出一段以5作为底数的Halton序列实现。

ComputeRadicalInversePermutation函数生成一个序列表的数组。首先生成一个足够长的数组,划分为以质数序列为长度的连续区域,每个区域中填充0~区域长度的数,之后每个区域分别打乱。

例如Array[ 0,1 0,1,2, 0,1,2,3,4 ]=>Array[ 1,0 1,2,0, 3,0,4,1,2 ]

std::vector<uint16_t> ComputeRadicalInversePermutations(RNG &rng) {
std::vector<uint16_t> perms;
int permArraySize = 0;
//Primes为一个长度为PrimeTableSize的质数表
//给perms数组分配空间,用于RadicalInverse
for (int i = 0; i < PrimeTableSize; ++i) permArraySize += Primes[i];
perms.resize(permArraySize);
uint16_t *p = &perms[0];
for (int i = 0; i < PrimeTableSize; ++i) {
for (int j = 0; j < Primes[i]; ++j) p[j] = j;
//打乱perms数组,p指针位置为起点,Primes[i]储存的值的长度范围的数据
Shuffle(p, Primes[i], 1, rng);
//偏移指针,相当于将起点平移Primes[i]储存的值的长度
p += Primes[i];
}
return perms;
}

ScrambledRadicalInverseSpecialized函数与RadicalInverseSpecialized函数类似,差别在于ScrambledRadicalInverseSpecialized基于ComputeRadicalInversePermutations生成的permutation表表,将生成一个打乱过的结果。

HaltonSampler

HaltonSample通过Halton序列生成样本向量,与分层采样不同的是,它是完全确定的。

permutation表被所有HaltonSampler共享使用(被声明为static std::vector<uint16_t>),并且在构造函数中就直接生成。这样设计有个好处:因为在多线程渲染的情况,每个线程会使用不同的采样器实例,但我们更倾向于使用同一个permutation表。

在构造函数中,为了将[0,1)的二维样本映射到像素坐标系中,PBRT会计算一个大于当前图片分辨率的最小缩放因子。为什么使用$2^j,3^k$作为缩放因子。应该是考虑到一维与二维采样分别是以2、3为底数计算的。(后面因为本人英语太差没有看懂)

 Vector2i res = sampleBounds.pMax - sampleBounds.pMin;
for (int i = 0; i < 2; ++i) {
int base = (i == 0) ? 2 : 3;
int scale = 1, exp = 0;
while (scale < std::min(res[i], kMaxResolution)) {
scale *= base;
++exp;
}
baseScales[i] = scale;
baseExponents[i] = exp;
} // Compute stride in samples for visiting each pixel area
sampleStride = baseScales[0] * baseScales[1]; // Compute multiplicative inverses for _baseScales_
multInverse[0] = multiplicativeInverse(baseScales[1], baseScales[0]);
multInverse[1] = multiplicativeInverse(baseScales[0], baseScales[1]);

(0,2)序列 采样器

与Halton和Hammersley不同,Sobol序列的每一个维度都是由底数为2的radical inversion组成,但每一个维度的radical inversion都有各自不同的矩阵C。$X_i:=(\Phi_{2, C_1}(i),...,\Phi_{2, C_n}(i))$

因为完全以2为底数,所以Sobol序列的生成可以直接使用bit位操作实现radical inversion,非常高效。Sobol序列的分布具有不仅均匀,而且当样本的个数为2的整数次幂时,在[0,1)^s区间中以2为底的每个Elementary Interval中都有且只会有一个点,这意味着它可以生成和Stratified Sampling和Latin Hypercube同样高质量分布的样本(见下图),同时又不需要预先确定样本的数量或者将样本储存起来,并可以根据需要生成无限个样本,非常适合progressive的采样。这些性质也使得Sobol在需要一切对高维空间采样的应用中

类名为ZeroTwoSequenceSampler,对于2D样本使用打乱后的(0,2)序列,1D样本则使用van der Corput序列。而且在构造函数中会将样本个数舍入为为2的整数次幂。因为继承自PixelSampler,所以在渲染前会先生成样本。

void ZeroTwoSequenceSampler::StartPixel(const Point2i &p) {
ProfilePhase _(Prof::StartPixel);
// Generate 1D and 2D pixel sample components using $(0,2)$-sequence
for (size_t i = 0; i < samples1D.size(); ++i)
VanDerCorput(1, samplesPerPixel, &samples1D[i][0], rng);
for (size_t i = 0; i < samples2D.size(); ++i)
Sobol2D(1, samplesPerPixel, &samples2D[i][0], rng); // Generate 1D and 2D array samples using $(0,2)$-sequence
for (size_t i = 0; i < samples1DArraySizes.size(); ++i)
VanDerCorput(samples1DArraySizes[i], samplesPerPixel,
&sampleArray1D[i][0], rng);
for (size_t i = 0; i < samples2DArraySizes.size(); ++i)
Sobol2D(samples2DArraySizes[i], samplesPerPixel, &sampleArray2D[i][0],
rng);
PixelSampler::StartPixel(p);
}
inline void VanDerCorput(int nSamplesPerPixelSample, int nPixelSamples,
Float *samples, RNG &rng) {
uint32_t scramble = rng.UniformUInt32();
// Define _CVanDerCorput_ Generator Matrix
const uint32_t CVanDerCorput[32] = {
//略
};
int totalSamples = nSamplesPerPixelSample * nPixelSamples;
//通过格雷码来生成VanDerCorput序列样本
GrayCodeSample(CVanDerCorput, totalSamples, scramble, samples);
//打乱操作
for (int i = 0; i < nPixelSamples; ++i)
Shuffle(samples + i * nSamplesPerPixelSample, nSamplesPerPixelSample, 1,
rng);
Shuffle(samples, nPixelSamples, nSamplesPerPixelSample, rng);
} inline void Sobol2D(int nSamplesPerPixelSample, int nPixelSamples,
Point2f *samples, RNG &rng) {
Point2i scramble;
scramble[0] = rng.UniformUInt32();
scramble[1] = rng.UniformUInt32(); // Define 2D Sobol$'$ generator matrices _CSobol[2]_
const uint32_t CSobol[2][32] = {
//略
};
//通过格雷码来生成Sobol序列样本
GrayCodeSample(CSobol[0], CSobol[1], nSamplesPerPixelSample * nPixelSamples,
scramble, samples);
for (int i = 0; i < nPixelSamples; ++i)
Shuffle(samples + i * nSamplesPerPixelSample, nSamplesPerPixelSample, 1,
rng);
Shuffle(samples, nPixelSamples, nSamplesPerPixelSample, rng);
}

结语

因为本人没有学过《数字图像处理》以及《信号与系统》,即使《全局光照技术》中有解析,我还是看不懂,所以放弃。胶片类部分也因为本人以后不太会去写离线渲染器而放弃。

PBRT笔记(6)——采样和重构的更多相关文章

  1. PBRT笔记(10)——体积散射

    体散射处理过程 3个影响参与介质在环境中的辐射度分布的主要因素: 吸收:减少光能,并将其转化为别的能量,例如热量. 发光:由光子发射光能至环境中. 散射:由于粒子碰撞,使得一个方向的辐射度散射至其他方 ...

  2. PBRT笔记(9)——贴图

    采样与抗锯齿 当高分辨率贴图被缩小时,贴图会出现严重的混淆现象.虽然第7章中的非均匀采样技术可以减少这种混叠的视觉影响,但是更好的解决方案是实现基于分辨率进行采样的纹理函数. 可以在使用贴图时先对贴图 ...

  3. PBRT笔记(14)——光线传播2:体积渲染

    传输公式 传输方程是控制光线在吸收.发射和散射辐射的介质中的行为的基本方程.它解释了第11章中描述的所有体积散射过程--吸收.发射和内.外散射.并给出了一个描述环境中辐射分布的方程.光传输方程实际上是 ...

  4. PBRT笔记(13)——光线传播1:表面反射

    采样反射函数 BxDF::Sample_f()方法根据与相应的散射函数相似的分布来选择方向.在8.2节中,该方法用于寻找来自完美镜面的反射和透射光线;在这里讲介绍实现其他类型的采样技术. BxDF:: ...

  5. PBRT笔记(12)——蒙特卡洛积分

    这里还涉及到pdf.方差等概念,推荐去看<全局光照技术:从离线到实时渲染> 积累分布函数 cumulative distribution function (CDF) 蒙特卡洛估算 为了计 ...

  6. PBRT笔记(11)——光源

    自发光灯光 至今为止,人们发明了很多光源,现在被广泛使用的有: 白炽灯的钨丝很小.电流通过灯丝时,使得灯丝升温,从而使灯丝发出电磁波,其波长的分布取决于灯丝的温度.但大部分能量都被转化为热能而不是光能 ...

  7. PBRT笔记(7)——反射模型

    基础术语 表面反射可以分为4大类: diffuse 漫反射 glossy specular 镜面反射高光 perfect specular 完美反射高光 retro-reflective distri ...

  8. PBRT笔记(5)——相机模型

    Camera class Camera { public: //实现相机在一定时间内进行特定的运动 AnimatedTransform CameraToWorld; //快门开/关数据,可以用于计算动 ...

  9. PBRT笔记(1)——主循环、浮点误差

    PBRT2与3之间的改动 增加了一个功能完备的BRDF模型,支持体积光照与重要性多重路径采样. 次表面散射,基于光线追踪技术,无需预处理. 解决浮点数四折五入的问题 光子映射 样本生成 第一章多了讲并 ...

随机推荐

  1. 五十八、linux 编程——UDP 编程 广播

    58.1 广播介绍 58.1.1 介绍 广播实现一对多的通讯 它通过向广播地址发送数据报文实现的 58.1.2 套接字选项 套接字选项用于修饰套接字以及其底层通讯协议的各种行为.函数 setsocko ...

  2. request redirection

    # encoding:utf-8 import reimport jsonimport randomfrom esdapi.config import BASE_URLfrom requests.se ...

  3. 【ShaderToy】基本操作——旋转

    *继续:ShaderToy基础学习中d(`・∀・)b 对每个像素点进行旋转,其实加个公式就ok了啊. 对网格进行旋转: 代码如下: #define TUTORIAL 2 #define PI 3.14 ...

  4. 关于js dtGrid报错长度的问题

    错误js截图 Uncaught TypeError: Cannot read property 'length' of undefined 翻译:Uncaught TypeError:无法读取未定义的 ...

  5. table-tree 表格树、树形数据处理、数据转树形数据

    前言 公司想搞个表格树的展示页面,看着element有个表格树,还以为可以用. 用出来只用表格没有树,研究半天没研究个所以然,只能从新找个 npm里找到一个:vue-table-with-tree-g ...

  6. iTOP-4412/4418/6818开发板-fastboot烧写脚本

    在 iTOP-4412,4418,6818 开发板烧写的时候,使用的是 fastboot 工具. fastboot 工具需要在 cmd.exe 中调用,每次都需要输入烧写命令,这样步骤有点多.在程序员 ...

  7. C#创建控制台项目引用Topshelf的方式,部署windows服务。

    上一篇是直接创建windows service服务来处理需求.调试可能会麻烦一点.把里面的逻辑写好了.然后受大神指点,用Topshelf会更好一些. 来公司面试的时候问我,为什么要用stringbui ...

  8. iis7.5做反向代理配置方法实例图文教程

    网络上好多开场的文章就说了好多的原理之类的这里我们直接开始配置.不过也要简单说下win下配置反向代理只有IIS7以上的版本才可以实现这个功能,在这里我们使用WINDOWS2008 R2来做为测试 20 ...

  9. 【interview】Microsoft面经

    ~~收集的面经~~ 1. 实现hashtable的put 和get操作 参考:https://yikun.github.io/2015/04/01/Java-HashMap%E5%B7%A5%E4%B ...

  10. [HTTP]POST报文中Content-Type对正文解析的影响

    概述 在POST请求中,理论上请求端程序可以发送任意格式报文正文,但是最好在报文头Content-Type字段标明正文的格式,方便接收端根据Content-Type正确处理正文. 传统HTML-for ...