目录

  • 1.什么是Compute Shader
  • 2.Compute Shader语法
    • 2.1 #pragma kernel
    • 2.2 Compute Shader中的变量
      • 2.2.1 标量
      • 2.2.2 向量
      • 2.2.3 矩阵(matrix)
      • 2.2.4 数组
      • 2.2.5 StructuredBuffer
      • 2.2.6 Texture
    • 2.3 numthreads与Dispatch
      • 2.3.1 numthreads
      • 2.3.2 线程为什么要分组
      • 2.3.3 numthreads中的xyz值何时最优
      • 2.3.4 Dispatch
    • 2.4 SV_DispatchThreadID
  • 3. Compute Shader灰度化图像
  • 4 参考文章

前两篇我们分别用OpenCV for Unity和片元着色器实现了图像灰度化的功能,今天我们来看看使用Compute Shader实现图像灰度化的功能。

1.什么是Compute Shader

定义
Compute Shader也是Shader中的一种,也运行在GPU上。它和我们平时说的顶点、片元着色器不同地方在于,Compute Shader不经过渲染流水线,而是用于通用计算。什么是通用计算呢?就是把之前在CPU的计算挪到GPU这边来。基于GPU强大的并行计算能力,与通过CPU计算相比能极大地缩短计算时间。使用GPU进行通用计算又叫GPGPU(General Purpose GPU Programming)编程。
适用场景
可以并行计算的地方,如图片的处理,视频编码。
我们这里想使用Compute Shader对图片进行灰度化。原因有二,①是图片上各个像素点灰度计算互不干扰,可以利用GPU的强大并行计算能力;②是通过GPU计算生成的图片可以不用再传回CPU而直接层递到屏幕,这也是个提升效率的地方。
性能瓶颈
如图,CPU和GPU之间的数据传输是瓶颈。

语言
Unity中Compute Shader是用什么语言写的呢?标准的DX11 HLSL

2.Compute Shader语法

如图,首先创建一个Compute Shader。

然后将下面灰度化纹理的Compute Shader拷贝过去,咱们来看看它的语法。

// 申明内核
#pragma kernel CSMain// 定义变量
Texture2D inputTex;
RWTexture2D<float4> outputTex;// 定义线程组内线程的数量
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{float r = inputTex[id.xy].r;float g = inputTex[id.xy].g;float b = inputTex[id.xy].b;float a = inputTex[id.xy].a;float gray = r * 0.299 + g * 0.587 + b * 0.114;outputTex[id.xy] = float4(gray, gray, gray, a);
}

2.1 #pragma kernel

用于定义内核,一个Compute Shader至少要有一个内核。内核其实就是下面定义的方法名,我们可以在CPU端命令GPU执行这个内核(ComputeShader.Dispatch,后面会讲到)。

#pragma kernel CSMain

上面这句代码就表示CSMain这个方法是一个内核。
在c#这边,我们通过使用ComputeShader.FindKernel去持有并记录到对应内核的索引值,以后向GPU传递数据时都需要传递它。
FindKernel的参数只有一个,即Compute Shader中内核名(由#pragma kernal指定的),返回值为该内核的索引。

// 参数为内核名(由#pragma kernal指定的),返回值为该内核的索引。
int kernal = cs.FindKernel("CSMain");

2.2 Compute Shader中的变量

Compute Shader语言是HLSL,所以HLSL支持的变量其都支持。
可去微软官方文档查看。
注:标量到数组部分摘自文章《DirectX11–HLSL语法入门》

2.2.1 标量

类型 描述
bool 32位整数值用于存放逻辑值true和false
int 32位有符号整数
uint 32位无符号整数
half 16位浮点数(仅提供用于向后兼容)
float 32位浮点数
double 64位浮点数

2.2.2 向量

向量类型可以支持2到4个同类元素。
3种申明向量的方式。
1.使用类似模板的形式来描述

vector<float, 4> vec1;  // 向量vec1包含4个float元素
vector<int, 2> vec2;    // 向量vec2包含2个int元素

2.直接在基本类型后面加上数字

float4 vec1;    // 向量vec1包含4个float元素
int3 vec2;      // 向量vec2包含3个int元素

3.使用vector本身则表示为一种包含4个float元素的类型**

vector vec1; // 向量vec1包含4个float元素

向量的初始化。

float2 vec0 = {0.0f, 1.0f};
float3 vec1 = float3(0.0f, 0.1f, 0.2f);
float4 vec2 = float4(vec1, 1.0f);

向量的使用与赋值。
向量的第1到第4个元素既可以用x, y, z, w来表示,也可以用r, g, b, a来表示。除此之外,还可以用索引的方式来访问。下面展示了向量的取值和访问方式:

float4 vec0 = {1.0f, 2.0f, 3.0f, 0.0f};
float f0 = vec0.x;  // 1.0f
float f1 = vec0.g;  // 2.0f
float f2 = vec0[2]; // 3.0f
vec0.a = 4.0f;     // 4.0f

我们还可以使用swizzles的方式来进行赋值,可以一次性提供多个分量进行赋值操作,这些分量的名称可以重复出现:

float4 vec0 = {1.0f, 2.0f, 3.0f, 4.0f};
float3 vec1 = vec0.xyz;     // (1.0f, 2.0f, 3.0f)
float2 vec2 = vec0.rg;      // (1.0f, 2.0f)
float4 vec3 = vec0.zzxy;    // (4.0f, 4.0f, 1.0f, 2.0f)
vec3.wxyz = vec3;           // (2.0f, 4.0f, 4.0f, 1.0f)
vec3.yw = ve1.zz;           // (2.0f, 3.0f, 4.0f, 3.0f)

2.2.3 矩阵(matrix)

矩阵有如下类型(以float为例):

float1x1 float1x2 float1x3 float1x4
float2x1 float2x2 float2x3 float2x4
float3x1 float3x2 float3x3 float3x4
float4x1 float4x2 float4x3 float4x4

此外,我们也可以使用类似模板的形式来描述:

matrix<float, 2, 2> mat1;  // float2x2

而单独的matrix类型的变量实际上可以看做是一个包含了4个vector向量的类型,即包含16个float类型的变量。matrix本身也可以写成float4x4:

matrix mat1; // float4x4

矩阵的初始化方式如下:

float2x2 mat1 = {1.0f, 2.0f,    // 第一行3.0f, 4.0f  // 第二行
};
float3x3 TBN = float3x3(T, B, N); // T, B, N都是float3

矩阵的取值方式如下:

matrix M;
// ...float f0 = M._m00;      // 第一行第一列元素(索引从0开始)
float f1 = M._12;       // 第一行第二列元素(索引从1开始)
float f2 = M[0][1];     // 第一行第二列元素(索引从0开始)
float4 f3 = M._11_12;   // Swizzles

矩阵的赋值方式如下:

matrix M;
vector v = {1.0f, 2.0f, 3.0f, 4.0f};
// ...M[0] = v;               // 矩阵的第一行被赋值为向量v
M._m11 = v[0];          // 等价于M[1][1] = v[0];和M._22 = v[0];
M._12_21 = M._21_12;    // 交换M[0][1]和M[1][0]

无论是向量还是矩阵,乘法运算符都是用于对每个分量进行相乘,例如:

float4 vec0 = 2.0f * float4(1.0f, 2.0f, 3.0f, 4.0f);    //(2.0f, 4.0f, 6.0f, 8.0f)
float4 vec1 = vec0 * float4(1.0f, 0.2f, 0.1f, 0.0f);    //(2.0f, 0.8f, 0.6f, 0.0f)

若要进行向量与矩阵的乘法,则需要使用mul函数。

2.2.4 数组

float M[4][4];
int p[4];
float3 v[12];   // 12个3D向量

除以上类型外,Compute Shader还支持StructuredBuffer和Texture,这两种类型也是我们实际开发中最常使用的。

2.2.5 StructuredBuffer

我们可以自定义结构体。结构体可以存放任意数目的标量,向量和矩阵类型,除此之外,它还可以存放数组或者别的结构体类型。

struct A
{float4 vec;
};struct B
{int scalar;float4 vec;float4x4 mat;float arr[8];A a;
};// 结构体的访问
B b;
b.vec = float4(1.0f, 2.0f, 3.0f, 4.0f);

在Compute Shader这边我们一般用StructuredBuffer和RWStructuredBuffer来存储结构体数组,StructuredBuffer对Compute Shader而言是只读的(只能通过C#设置值),RWStructuredBuffer可读®可写(W)。
它们两者在C#这边对应的类是ComputeBuffer。
StructuredBuffer、RWStructuredBuffer和ComputeBuffer如何使用见下面这个例子。
Compute Shader:

struct Vertex {float3 pos;float2 uv;
}RWStructuredBuffer< Vertex > buffer;

C#:

// 定义与Compute Shader中结构体相同的结构体
struct Vertex
{public Vector3 pos;            // c#中的Vector3对应Compute Shader中的float3可对应public Vector2 uv;         // c#中的Vector2可对应float2
}private void RunShader()
{// 定义结构体数组Vertex[] vertex = new Vertex[64];Vertex[] result = new Vertex[64];// 初始化vertex数据...// 示例化一个ComuteBuffer,用于和Compute Shader的RWStructuredBuffer交互// ComputeBuffer构造函数需指定“数组的长度”, “一个结构体所占用的字节数”// 我们的结构体中一共5个float,一个float占4个字节,所以该结构体占20个字节ComputeBuffer buffer = new ComputeBuffer(vertex.Length, 20);// vertex的设置给ComputeBufferbuffer.SetData(vertex);// 加载ComputeShaderComputeShader cs = Resources.Load<ComputeShader>("xxx");// FindKernel("内核"),获取到内核Idint kernel = cs.FindKernel("CSMain");// 将buffer中的值赋值给Compute Shader中的buffer,将两者联系起来cs.SetBuffer(kernel, "buffer", buffer);// 执行kernal这个内核cs.Dispatch(kernel, 8, 8, 1);// 从Compute Shader中读取计算后的值,结果存在result中buffer.GetData(result);// 不再使用时释放内存buffer.Dispose();
}

从例子可以看出,c#中的Compute Buffer与Compute Shader中StructuredBuffer\RWStructuredBuffer的交互主要就以下几个方法。
1.Compute Buffer实例化,需指定数组的长度,和数组中单个元素占用的字节数

ComputeBuffer buffer = new ComputeBuffer(vertex.Length, 20);

2.使用ComputeBuffer.SetData,传入一个数组,将该数组中的值拷贝到ComputeBuffer
需要注意的是数组的长度和单个元素占的字节数要和buffer初始化是指定的值相同。

// vertex的设置给ComputeBuffer
buffer.SetData(vertex);

3.使用ComputeShader.SetBuffer将buffer指定给Compute Shader中StructuredBuffer\RWStructuredBuffer,参数分别是内核名、Compute Shader中StructuredBuffer\RWStructuredBuffer的变量名、要传递的ComputeBuffer

// 将buffer中的值赋值给Compute Shader中的buffer,将两者联系起来
cs.SetBuffer(kernel, "buffer", buffer);

4.ComputeShader.Dispatch执行ComputeShader
Dispatch中的参数是什么意思?后面我们会讲。
5.最后从Compute Shader中读取计算后的结果,方法为ComputeBuffer.GetData

// 从Compute Shader中读取计算后的值,结果存在result中
buffer.GetData(result);

2.2.6 Texture

Texture2D< float4 > xx;                    // 只读
RWTexture2D< float4 > xx;             // 读写
RWTexture2D< float2 > xx;                 // RG_int

Texture2d< float4 >对应c#中的Texture2D。
RWTexture2d< float4 >对应c#中的RenderTexture。

在Unity里读写的只能是RenderTexture并且支持随机读写(RenderTexture.enableRandomWrite = true)

与StructuredBuffer类似,c#中使用ComputeShader.SetTexture(内核Id, “ComputeShader中的Texture或RWTexture2D变量名”, c#中的Texture2D或RenderTexture)

cs.SetTexture(kernal, "inputTex", inputTexture);

2.3 numthreads与Dispatch

Compute Shader利用了GPU的并行处理,这里的并行处理是指Compute Shader中的内核方法会被GPU中的多个线程同时运行。
这里的多个线程到底是多少个,是由我们自己确定的。这些线程需要被拆分为多个线程组,一个线程组中包含一定数量的线程,具体方式是通过numthreads和Dispatch来设置。
比如我们可以通过设置numthreads和Dispatch来让一个线程单独处理图像上的一个像素点。

但要注意这种处理是无序的随机的,并不一定是固定的处理顺序,例如不一定是从左到右挨个处理像素点。

2.3.1 numthreads

我们看到在内核方法的上方,加上了一个[numthreads(8,8,1)]前缀。

[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{// ...
}

numthreads它的作用是什么呢?它是用来指定线程组(Threads Group)中线程的数量。
什么是线程组呢?可以简单将其理解为是一堆线程的组合。一个线程组,包含有很多线程。
那这么多线程,在线程组中是怎么排列的?类似长方体的排列。
numthreads后面指定了x,y,z三个方向各有多少个线程。画了个图,相信大家一看就明白了。
比如我们指定[numthreads(8,4,2)],其指定了x、y、z轴各8个、4个、2个线程,所以一个线程组共842=64个线程。其线程排列如下,其中一个小立方体代表一个线程(小立方体内的编号我们暂时用不上,但是后面谈到SV_DispatchThreadID的时候我们会用到)。

又比如[numthreads(8,8,1)],其指定x、y、z轴各8个、8个、2个线程,所以一个线程组共881=64个线程。排列规则如下。

2.3.2 线程为什么要分组

问题来了,我们把多个线程合成一个线程组的意义在哪儿,有什么好处?
答案是在同一个组的线程咱们可以共享变量,并能够将它们设置为同步

2.3.3 numthreads中的xyz值何时最优

GPU一次Dispatch会调用64(AMD成为wavefront)或32(NVIDIA称为warp)个线程(这实际上是一种SIMD技术),所以,numThreads的乘积最好是这个值的整数倍。但是Mali不需要这种优化。此外,Metal可以通过API获取这个值。

numthreads的数值除了应该为32或64的整数倍外,也是有大小限制的,具体如下表所示。

Compute Shader Maximum Z Maximum Threads (XYZ)
cs_4_x 1 768
cs_5_0 64 1024

可以鼠标右键选中一个Compute Shader,然后点击Show compiled code,打开的汇编语言里面有说明是哪个版本,是cs_4_x还是cs_5_0。

鉴于以上两个条件,我一般在工作中最常使用numthreads(8, 8, 1)。

2.3.4 Dispatch

ComputeShader.Dispatch用来指定有多少个线程组。
Dispatch用法如下,第一个参数为内核索引值,后面三个参数分别为x、y、z轴的线程组数量。

cs.Dispatch(kernal, 1024/8, 768/8, 1);

比如我们设定cs.Dispatch(kernal, 4, 4, 2),则线程组的排列规则如下。

如果同时numthreads(8, 8, 1),则numthreads和Dispatch的关系如下。

两者的关系,咱们还可以结合nvida和微软的两张图来理解。

图片来源:https://www.nvidia.com/content/GTC-2010/pdfs/2260_GTC2010.pdf

图片来源:https://docs.microsoft.com/en-us/windows/win32/direct3dhlsl/sm5-attributes-numthreads

两者的关系弄清楚后,问题又来了,Dispatch中的xyz值怎么设置才是最优的?
这里有一个原则:

A Thread Group 运行在一个GPU单元 (A single multiprocesser),如果GPU有16个
multiprocesser,那么程序至少要分成16个 Thread Group使得每个multiprocesser都参与计算。
组之间不分享内存。

当然这个原则不是必须的,只是遵循这样的原则效率最高。
一个常用的经验是Dispatch(kernal, 图片长(像素)/numthreads中的x值,图片宽(像素)/numthreads中的y值, 1),即将图像的尺寸除以numthreads的xy。
同时需要注意的是,这样做要满足上面的原则的话,图片的长宽像素最好均为32的整数倍。

比如我们要处理的图像像素为1024×768,numthreads[8, 8, 1],那么按照经验应该设置Dispatch(kernal, 1024/8, 768/8, 1)。

2.4 SV_DispatchThreadID

[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{// ...
}

SV_DispatchThreadID用标识当前线程在整个线程组中的位置,是一个三维向量。
由于我们通常通过Dispatch和numthreads来让一个线程处理一个像素点,所以可以通过SV_DispatchThread来获取到当前处理的像素点。
当然除了SV_DispatchThreadID外,我们还可以使用SV_GroupID、SV_GroupThreadID、SV_GroupIndex。

变量名 含义
SV_GroupThreadID 当前线程在该线程组中的位置
SV_GroupID 当前线程所在线程组的位置
SV_DispatchThreadID 当前线程在整个线程组中的位置
SV_GroupIndex 当前线程在该线程组中的索引值

说到这里,Compute Shder的语法就讲得差不多了,最后,咱们去Unity官网看看ComputeShader类的完整API。

3. Compute Shader灰度化图像

Compute Shader:

#pragma kernel CSMainTexture2D<float4> inputTex;
RWTexture2D<float4> outputTex;[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{float r = inputTex[id.xy].r;float g = inputTex[id.xy].g;float b = inputTex[id.xy].b;float a = inputTex[id.xy].a;float gray = dot(inputTex[id.xy].rgb, float3(0.299, 0.587, 0.114));outputTex[id.xy] = float4(gray, gray, gray, a);
}

C#:

using UnityEngine;
using UnityEngine.UI;public class GrayDemo : MonoBehaviour
{private ComputeShader m_GrayComputeShader;private RawImage m_GrayImg;private void Start(){m_GrayImg = GameObject.Find("Canvas/RawImg-ComputeShader").GetComponent<RawImage>();Texture2D inputTexture = Resources.Load<Texture2D>("flower_art_0EN071");RenderTexture rt = new RenderTexture(inputTexture.width, inputTexture.height, 24);rt.enableRandomWrite = true;rt.Create();m_GrayImg.texture = rt;m_GrayImg.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, rt.width);m_GrayImg.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, rt.height);m_GrayComputeShader = Resources.Load<ComputeShader>("ComputeShader/Gray");int kernal = m_GrayComputeShader.FindKernel("CSMain");m_GrayComputeShader.SetTexture(kernal, "inputTex", inputTexture);m_GrayComputeShader.SetTexture(kernal, "outputTex", rt);m_GrayComputeShader.Dispatch(kernal, 1024/8, 768/8, 1);}
}

效果如下。

项目链接。
链接:https://pan.baidu.com/s/1V6gqXlKN_YREpiaFXd_Wfg
提取码:z77y
博主个人博客本文链接。

4 参考文章

  • Shader第二十八讲 Compute Shaders
  • Compute Shader次世代优化方案
  • Unity中的ComputeShader
  • Unity Compute Shader入门初探
  • Unity 3D : ComputeShader 全面詳解
  • DirectX11–HLSL语法入门
  • Unity3d | 浅谈 Compute Shader
  • 在Unity开始你的Compute Shader编写
  • OpenGL-Compute Shader的输入和输出
  • Unity Compute Shader Thread Groups 线程组
  • CUDA编程——GPU架构,由sp,sm,thread,block,grid,warp说起
  • https://www.nvidia.com/content/GTC-2010/pdfs/2260_GTC2010.pdf
  • https://docs.microsoft.com/en-us/windows/win32/direct3dhlsl/sm5-attributes-numthreads
  • https://docs.unity3d.com/2020.2/Documentation/ScriptReference/ComputeShader.html
  • 微软HLSL官方文档

Unity3D Shader系列之Compute Shader基础及图像灰度化相关推荐

  1. Unity3D Shader系列之UI Image灰度化

    目录 1.灰度化是什么 2.灰度值计算方法 3.灰度化的目的 4.Shader实现 5.参考文章 在<OpenCV for Unity学习笔记(1)--Mat及灰度化图像>我们看到,使用O ...

  2. Unity3D Shader系列之描边

    目录 1 引言 2 顶点沿法线外拓方式 2.1 法线外拓+ZTest Always 2.1.1 代码 2.1.2 问题点 2.2 法线外拓+Cull Front 2.2.1 代码 2.2.2 改进点 ...

  3. Unity3D Shader系列之全息投影

    1 效果展示 2 实现原理 全息投影其实是几个效果的叠加:①半透明效果②上下条纹的扫描效果③边缘光效果④模拟信号传输不稳定的顶点偏移效果. 咱们依次来看看这几个效果背后的原理. ①半透明效果 在Uni ...

  4. 【Unity3D Shader编程】之十三 单色透明Shader 标准镜面高光Shader

    本系列文章由@浅墨_毛星云 出品,转载请注明出处.   文章链接: http://blog.csdn.net/poem_qianmo/article/details/50878538 作者:毛星云(浅 ...

  5. 【Unity3D Shader编程】之十三 单色透明Shader 标准镜面高光Shader

    分享一下我老师大神的人工智能教程!零基础,通俗易懂!http://blog.csdn.net/jiangjunshow 也欢迎大家转载本篇文章.分享知识,造福人民,实现我们中华民族伟大复兴! 本系列文 ...

  6. Compute Shader次世代优化方案

    这是侑虎科技第498篇文章,感谢作者凯奥斯供稿.欢迎转发分享,未经作者授权请勿转载.如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨.(QQ群:793972859) 作者主页:https://z ...

  7. Directx 计算着色器(compute shader)

    原文 :http://www.cnblogs.com/Ninputer/archive/2009/12/11/1622190.html 博者注:计算着色器调试(http://msdn.microsof ...

  8. Shader Forge 入门学习(一) 基础操作

    引言:失踪人口回归,最近几个月刚刚毕业,进入社会,对着未来有着些许迷茫,但起风了,唯有努力生存!近日学习Shader Forge,记录下来,共同进步!内容主要包括ShaderForge的操作设置,并配 ...

  9. android device monitor命令行窗口在哪里_Vulkan在Android使用Compute shader

    oeip 相关功能只能运行在window平台,想移植到android平台,暂时选择vulkan做为图像处理,主要一是里面有单独的计算管线且支持好,二是熟悉下最新的渲染技术思路. 这个 demo(git ...

  10. OpenGL Compute Shader计算着色器的实例

    OpenGL Compute Shader计算着色器 先上图,再解答. 完整主要的源代码 源代码剖析 先上图,再解答. 完整主要的源代码 // #define USE_GL3W #include &l ...

最新文章

  1. Ajax 的优势和不足
  2. Apache Solr schema.xml及solrconfig.xml文件中文注解
  3. 解决Ext JS 4.1版本Tree在刷新时选择第一行的问题
  4. thinkphp 查找表并返回结果
  5. 当系统扩展遇到违背OO的里氏原则(LSP)的时候怎么办 ?
  6. ORACLE+RAC+ASM环境下添加redo日志组
  7. 「C++」C++ Primer Plus 笔记:第三章 处理数据
  8. 晟数学院 oracle,Oracle 控制文件存储解析
  9. react-native flatlist 上拉加载onEndReached方法频繁触发的问题
  10. MySQL Internals Manual
  11. Hack.Chat 在浏览器里快速建立简单、随用即丢线上聊天室,无须下载安装软体
  12. Perl用LWP实现GET/POST数据发送
  13. 国内最全最详细的hadoop2.2.0集群的HA高可靠的最简单配置
  14. Solr Wiki文档
  15. Java中多态的一些简单理解
  16. 一篇文章带你搞定数学建模中的 Malthus人口模型(含代码)
  17. 联想数据中心技术总监单奖定:双态IT -数字化转型下的IT建设新思路
  18. 你在被窝里刷手机岁月静好,一个名叫 Flink 的 ​“神秘引擎” 却在远方和时间赛跑...
  19. 机械臂技术参数的意义
  20. ST-LINK/V2:cannot reset target shutting down debug session

热门文章

  1. 手机端APP防盗链配置问题
  2. windows10双系统删除linux,双系统删除教程详解:Windows(linux)双系统,教你如何删除其中一个!...
  3. 新能源与材料如何应用计算机,计算机模拟在光电材料及太阳能工程领域的应用与新进展.doc...
  4. python井字棋_python之井字棋游戏
  5. 快速 二进制,八进制,十进制,十二进制转换 .源码,反码,补码,
  6. excel下拉公式保持一些参数不变
  7. 单片机和嵌入式设计的区别
  8. 计算快递费系统(java版)
  9. 415错误及解决方法
  10. R-概率统计与模拟(四)拒绝抽样